Skip to main content

POST /v1/webhook/incoming

This endpoint is hosted on a separate Worker — questkit-worker-webhook-relay — at https://webhook.questkit.jairukchan.com/v1/webhook/incoming. It accepts inbound webhooks from third-party providers (Stripe in v0.1, more providers planned), verifies the HMAC signature, normalises the payload into a QuestKit Event, and produces to a Cloudflare Queue for async ingestion.

The relay returns 202 Accepted as soon as the message is on the queue. The consumer drains the queue at its own pace and calls the API's ingest pipeline via typed WorkerEntrypoint RPC.

Stripe ──► /v1/webhook/incoming ──► queue ──► consumer ──RPC──► api ingest
(HMAC verify) (at-least-once) (idempotent)

Request

curl -X POST https://webhook.questkit.jairukchan.com/v1/webhook/incoming \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=1716100000,v1=5257a869e7ec...hex..." \
-d '{
"id": "evt_abc123",
"type": "payment_intent.succeeded",
"created": 1716100000,
"data": {
"object": {
"customer": "usr_demo_123",
"amount": 4999
}
}
}'
HeaderRequiredDescription
Stripe-SignatureyesStripe-format t=<unix-seconds>,v1=<hex-sha256>. See HMAC.
Content-TypeyesMust be application/json.

Response — 202 Accepted

{
"accepted": true,
"eventId": "evt_abc123"
}

eventId is evt_<rawPayload.id> — it doubles as the QuestKit Event.idempotencyKey, so duplicate deliveries from Stripe (or the queue's at-least-once semantics) collapse cleanly at the API layer.

Errors

HTTPerror codeMeaning
400malformed_signatureStripe-Signature header missing pieces or wrong shape.
400invalid_jsonBody wasn't parseable JSON.
400invalid_payload_rootBody wasn't an object.
400invalid_idMissing or non-string id.
400invalid_typeMissing or non-string type.
400invalid_createdMissing or non-numeric created.
400invalid_data_objectMissing or non-object data.object.
401invalid_signatureHMAC didn't match.
401signature_expiredTimestamp outside the ±300 s replay window.

HMAC verification

The relay computes HMAC-SHA256(WEBHOOK_HMAC_SECRET, "${t}.${rawBody}") and verifies it against the v1= part of the header using crypto.subtle.verify (timing-safe by spec). Bytes-for-bytes — any whitespace change in the JSON body invalidates the signature.

See Webhooks → HMAC for the signing recipe and a code sample.

Idempotency & queue semantics

  • Each message carries Event.idempotencyKey = "evt_${rawPayload.id}", which the API's ingest pipeline uses to deduplicate.
  • The queue is at-least-once. Treat duplicates as expected; the idempotency layer handles them.
  • After 5 failed retries on the consumer side, the message lands in questkit-queue-webhooks-dlq. See Webhooks → DLQ.

Note on the relay's responsibilities

The relay does not rate-limit, write to D1, or call Workers AI. Its job is to verify the signature, parse the JSON, normalise, and enqueue — minimal so the cold start stays fast under public-internet traffic.