CorePass
Start integrating

Webhooks and callbacks

Every callback CorePass Connector sends, how each one is signed, how to verify it without getting it wrong, and what happens when your endpoint is down.

8 minute read · Updated 2026-09-05


Everything CorePass Connector tells your application after a request has left your hands arrives as an HTTP POST you have to verify. There are two families of them, they are signed differently on purpose, and the mistakes that make a verification worthless are well known and easy to avoid.

Two callbacks, two schemes #

KYC KYB
Where it goes The callback and statusCallback URLs you pass on each request. The one endpoint you registered, and nowhere else.
Header Corepass-Signature Corepass-KYB-Signature
Scheme t=, s= t=, v1=, kv=
Body casing camelCase snake_case
Where the values are In the body, base64 inside base64. Inside an RS256 attestation, and nowhere else.
Is the header the security boundary? Yes. No — the attestation is. The header is transport hygiene.

The KYB header is deliberately not named Corepass-Signature, so a KYC verifier cannot be pointed at KYB traffic. The two use different secrets and different schemes.

The KYC callbacks #

Your statusCallback receives one POST per status change:

status.updated
{
  "event": "status.updated",
  "timestamp": 1677255437,
  "data": {
    "userAddress": "ab148af5f9cdad10beddb05fbec4a3bef02577130e56",
    "fields": ["21148787cbc13cfc8acde462c822ff431af96aa2edf714fdc82f90e4c2918824"],
    "status": "initiate_submitted",
    "deadline": 1677255497,
    "txHash": "0xd90eb185877e47238380f613e3ac77f4cdb6a293ae21019fea7ac1b9ce12a94e",
    "timestamp": 1677255437
  }
}

Your callback receives one POST when the transfer completes. The body is JSON — not multipart/form-data:

data.transferred
{
  "event": "data.transferred",
  "timestamp": 1667137346,
  "data": {
    "userAddress": "ab432e666932c53128d9f73712b058a7a8f7df52f5cb",
    "infos": "W3siZmllbGQiOiJEUklWRVJfTElDRU5TRV9ET0IiLCJkYXRhIjoiTVRrNE5pMHdPUzB3Tmc9PSIsInBlcHBlciI6Ik16YzBaVGMwIn1d",
    "deadline": 1667137346,
    "txHash": "0x1332a0079b54bace370e216f32bb4284adb7a4852c47a71908ec2c6152145114",
    "timestamp": 1667137346
  }
}

The event member is what lets one receiver tell the two apart without looking at the rest of the body. A failed transfer reaches you as a status callback carrying the failure status, not as a separate body with an error member.

There is no signature member in either body, and no expiration member. An older revision of the integration notes described a body-level signature you were told to verify by recovering a public key from a hash of the encoded JSON. That is not what is sent. Authenticity is the header.

Verifying a KYC callback #

The header
Corepass-Signature: t=<unix seconds>,s=<hex hmac-sha256>

s is HMAC-SHA256, keyed with the webhook secret registered for your domain, over the string "<t>.<raw request body>". t is the same number as the body's top-level timestamp, and it is inside the MAC — which is the only reason comparing it to a clock is worth anything.

Go
func verifyCallback(header string, rawBody []byte, secret string) error {
	var timestamp int64
	var mac string
	for _, pair := range strings.Split(header, ",") {
		name, value, _ := strings.Cut(strings.TrimSpace(pair), "=")
		switch name {
		case "t":
			timestamp, _ = strconv.ParseInt(value, 10, 64)
		case "s":
			mac = value
		}
	}

	// Against YOUR clock. Never against a timestamp taken out of the body:
	// both sides of that comparison come from the sender, so it rejects
	// nothing.
	if age := time.Since(time.Unix(timestamp, 0)); age > 5*time.Minute || age < -5*time.Minute {
		return errors.New("stale callback")
	}

	expected, err := hex.DecodeString(mac)
	if err != nil {
		return errors.New("malformed signature")
	}

	// Over the RAW bytes you received. Re-serialising the parsed JSON first
	// produces a different byte string on member order and fails on every
	// genuine delivery.
	digest := hmac.New(sha256.New, []byte(secret))
	fmt.Fprintf(digest, "%d.", timestamp)
	digest.Write(rawBody)

	if !hmac.Equal(digest.Sum(nil), expected) {
		return errors.New("bad signature")
	}
	return nil
}

Three ways to write a check that verifies nothing, all of which have been shipped somewhere:

  1. Never computing the MAC at all.
  2. Comparing t against a timestamp inside the body — both sides of that comparison come from the sender, so it rejects nothing.
  3. MACing a re-serialisation of the parsed JSON instead of the bytes you received. Member order changes, and it then fails on every genuine delivery.

Retries, and running out of them #

CorePass expects a 200. Anything else is a failure, and the callback is retried on the intervals the deployment is configured with — a list of numbers and the unit they are counted in:

Connector callback service
NATS_CALLBACK_TOPICS="1,5,10,120,124000"
NATS_CALLBACK_DURATION=Minute

With that configuration your callback is called again after 1 minute, then 5, then 10, then 120, then 124 000 minutes — about 34 days — and then the retries stop. When they stop, the data is not lost: it sits in the connector's database, and it has to be exported and removed by hand. That is a manual operation for somebody, so it is worth answering 200 promptly.

The KYB deliveries #

Headers on every delivery
Corepass-KYB-Signature: t=<unix>,v1=<hex hmac-sha256>,kv=<secret version>
Corepass-Event-Id: 01K3QW8P4Z0M5R7T9V2X6B4Y8C
Corepass-Delivery-Attempt: 1
Corepass-Kyb-Api-Version: 2026-09-05

Corepass-Delivery-Attempt is this attempt's number, from 1. It is a header rather than a body member so the body stays byte-identical across retries and remains cacheable by its event id.

Delivery is at least once and in order per stream: seq 2 is not sent until seq 1 has been delivered or has permanently stopped being retryable. Anything other than a 2xx is retried on an exponential schedule for roughly nine hours — and a slow 200 costs you the same retry a 500 would, so answer fast and do the work afterwards.

Sustained failure quarantines your endpoint. Deliveries stop and events accumulate rather than being dropped; reactivating the endpoint sends the backlog.

Verifying a KYB delivery #

Check the header the same way you check the KYC one — your own clock, the raw bytes, a constant-time comparison — and then verify the attestation, which is where the security actually lives. The five checks are on the KYB page, along with the reference verifiers for Go and TypeScript.

The short version, in the order it has to happen:

  1. The token type is kyb-attestation+jwt, checked before the signature.
  2. RS256, as a constant — never read out of the header.
  3. The signature verifies against the key named by its id.
  4. Issuer, audience and the times hold, with a small skew leeway.
  5. The envelope's identifiers equal the signed copies inside the claims.

Verify whenever a token is present, whether or not you recognise the event type. A verifier that branches on the types it knows would hand your application a new attested event with its signature ignored.

Dedupe and redelivery #

The event id is a 26-character ULID. It is byte-identical on every retry and on every operator redelivery, and it is the only dedupe key — not the timestamp, not a hash of the body. Both reference verifiers ship a working in-memory guard; replace it with a unique index on the event id in your database before you run more than one replica.

A duplicate should answer 2xx and do nothing. If you cannot reach your own store to find out whether you have seen an event, answer 503 and let CorePass retry, rather than guessing.

Two reads settle any argument about what was sent: a per-attempt delivery history with the status, the HTTP code, the error and the next attempt, and the event stream itself with the attestation attached while it is retained. A KYB release can also be pulled from the API while the payload is retained — for a receiver that was down during the window — and a pulled token is verified exactly as a delivered one, because the API key proves who is asking, not what the token says.

A receiver checklist #

  • Read the raw body before anything parses it, and keep those bytes.
  • Verify the signature over those bytes, against your own clock.
  • For KYB, verify the attestation whenever one is present, and refuse a released event that carries none.
  • Dedupe on the event id, with a unique index rather than a map in one process.
  • Answer 2xx quickly, then do the work; answer 503 when your own store is unreachable.
  • Store the pepper with every KYC value — you cannot re-check the value without it, and each transfer costs you.
  • Never follow a redirect of your own into the delivery path: a KYB endpoint that 302s is refused, and it is worth not relying on that.