# Receive and verify webhooks Receive Thor events without performing the same business action twice. Verify the delivery, accept it durably, and process slow work separately from the HTTP request. ## Before you begin Use a reachable HTTPS endpoint, secret storage, and a durable inbox or queue. Select only the event types your integration needs from [WebhookEventType](/api/admin-graphql/enums/webhook-event-type). For an embedded app, prefer the template's `authenticate.webhook(request)` helper; see [App authentication and sessions](/guides/apps/authentication). ## 1. Create a subscription Admin API ```graphql mutation SubscribeToOrders($input: WebhookSubscriptionCreateInput!) { webhookSubscriptionCreate(input: $input) { webhookSubscription { id url isActive secret subscribedEvents } errors { __typename ... on UserError { message } } } } ``` Variables ```json { "input": { "url": "https://your-app.example/webhooks/thor", "events": [ "ORDER_CREATED" ], "isActive": true } } ``` Replace the URL with your receiver. Store the returned subscription secret securely and associate it with the project and subscription in your receiver configuration. Do not log the entire mutation response because it contains the secret. ## 2. Verify the raw body Thor sends a JSON event envelope and a `Thor-Signature` header shaped as `t=,v1=`. The signed payload is the timestamp, a period, and the exact raw body. Use HMAC-SHA256 with the subscription secret and a constant-time comparison. Do not parse and reserialize the body before verification. This Node example verifies one signature and returns the parsed envelope. Its five-minute tolerance is a receiver policy, not a promised delivery latency: Verify a webhook ```typescript import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyThorEvent(rawBody: Buffer, signature: string, secret: string) { const parts = signature.split(",").map(part => part.trim()); const timestamp = parts.find(part => /^t=\d+$/.test(part))?.slice(2); const digest = parts.find(part => /^v1=[a-f0-9]{64}$/i.test(part))?.slice(3); if (!timestamp || !digest) throw new Error("Invalid webhook signature"); const seconds = Number(timestamp); if (!Number.isSafeInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) { throw new Error("Webhook timestamp outside tolerance"); } const expected = createHmac("sha256", secret) .update(`${timestamp}.`) .update(rawBody) .digest(); const actual = Buffer.from(digest, "hex"); if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { throw new Error("Invalid webhook signature"); } const event: unknown = JSON.parse(rawBody.toString("utf8")); if (!event || typeof event !== "object" || !("idempotency_key" in event) || typeof event.idempotency_key !== "string" || !("type" in event) || typeof event.type !== "string") { throw new Error("Invalid webhook envelope"); } return event; } ``` Read the request body once as bytes, pass the header value and configured secret to this function, and return a non-success response for invalid deliveries. Apply a request-size limit appropriate for your endpoint before buffering the body. Headers such as `Thor-Project`, `Thor-Topic`, and `Thor-Event-Id` are useful routing or diagnostic values; do not treat them as independently authenticated by a signature over the body. Use your configured subscription/project association for authorization decisions. ## 3. Accept once, process safely The envelope includes `id`, `object`, `created`, `idempotency_key`, `data`, and `type`. Store its verified idempotency key in a uniquely constrained inbox scoped to your configured project or integration. Atomically accept the event and make its work recoverable, then return a prompt `2xx` response. A duplicate that is already durably accepted can be acknowledged without another business action. Keep processing idempotent as well: a worker can fail after an external action but before marking the inbox complete. Use a stable destination reference and reconcile the external result on retry. Do not assume deliveries arrive in order; read current resource state when applying an older event could overwrite newer data. ## 4. Inspect and resend Inspect [webhookDelivery](/api/admin-graphql/queries/webhook-delivery) when a receiver fails. [webhookDeliveryResend](/api/admin-graphql/mutations/webhook-delivery-resend) resends a delivery while retaining its original idempotency key. It should not create another business action in a correctly deduplicated receiver. ## Verify Test the original event, an identical resend, an altered body, a wrong secret, a stale timestamp, and a worker restart after acceptance. Confirm one durable event and one intended business outcome. Do not use real customer or payment payloads in public examples or test fixtures.