Skip to documentationDocumentation
On this page

Signature verification

Verify the event ID, timestamp, and exact webhook body with HMAC-SHA256.

Verify a webhook before parsing its data or performing a business action. Use the secret issued for that endpoint, not an API key.

Signature format#

Read these two headers from the incoming request:

  • X-Halfin-Event-Id: the event UUID.
  • X-Halfin-Signature: t=<unix-seconds>,v1=<64-lowercase-hex-characters>.

Join the event ID, a period, the timestamp, another period, and the exact raw body bytes. Compute HMAC-SHA256 over that message using the endpoint secret as UTF-8 bytes, then encode the digest as lowercase hexadecimal.

The timestamp is the delivery attempt's Unix time in seconds. The event ID comes from X-Halfin-Event-Id; it must also match the JSON body's id. Preserve the exact body bytes, including whitespace and encoding. Parsing and serializing the JSON again changes the signed message.

Use the endpoint secret as UTF-8 text without decoding it. Although it looks like base64url, decoding it produces the wrong HMAC key. The older formula that signs only timestamp and body is incompatible with this contract.

Verification steps#

  1. Capture the raw request bytes before JSON middleware consumes them.
  2. Require a canonical lowercase UUID event ID, one numeric t, and at least one valid v1 digest. Reject duplicate timestamps or malformed signature candidates.
  3. Require the attempt timestamp to be within 300 seconds of your server's trusted clock, past or future. Keep the clock synchronized.
  4. Compute HMAC-SHA256 over event ID, timestamp, separators, and exact body bytes.
  5. Compare digests with a constant-time comparison. Accept a matching known secret and signature candidate.
  6. Parse JSON and check that event.id equals the signed header ID. Validate the expected merchant, environment, event type, and schema version before applying business logic.
  7. Durably deduplicate by event ID and make the business action idempotent. A valid signature does not prevent replay within the time window.

Apply reasonable input-size limits before parsing signature headers. The current sender emits one v1 signature. If your receiver accepts multiple signature candidates or known secrets, reject malformed candidates and duplicate timestamps; this does not promise an automatic secret-rotation feature.

Test vector#

This is a public cryptographic test vector. Its secret is hex-encoded test bytes, which you must decode for this vector only. Production endpoint secrets are used directly as UTF-8 text. The fictional invoice.confirmed in this vector is not a subscribable event.

json example
{
  "secret_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
  "event_id": "00000000-0000-4000-8000-000000000001",
  "timestamp": "1700000000",
  "body": "{\"id\":\"00000000-0000-4000-8000-000000000001\",\"type\":\"invoice.confirmed\"}",
  "signature_hex": "571518828ef6d822dd6c6b95c2bc05ca4969e748f06a8f859f219f75f44914dc",
  "header": "t=1700000000,v1=571518828ef6d822dd6c6b95c2bc05ca4969e748f06a8f859f219f75f44914dc",
  "verification_clock": 1700000120
}

Verify it using the stated test clock and a 300-second window. With today's clock it must fail as expired. Changing the event ID, timestamp, any body byte, or a digest character must fail.

After verification#

Check the parsed JSON id against the signed header value. Persist the verified event in an inbox with an event-ID uniqueness constraint, then return 2xx within 15 seconds and process asynchronously. Duplicates should receive 2xx without repeating the business effect.

Do not return success merely because an in-memory queue accepted an event. Do not log raw secrets or echo them in your response. See delivery, secrets, and business processes.

Common mistakes#

MistakeFix
Sign only timestamp and bodyInclude X-Halfin-Event-Id before the timestamp
Parse and reserialize JSON before verificationPreserve raw bytes
Base64-decode the endpoint secretUse its exact UTF-8 string
Compare hex digests with ordinary equalityUse the language's constant-time primitive
Trust a signature foreverEnforce a maximum 300-second past/future window
Deduplicate by delivery IDUse event ID, plus idempotent business actions