> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sparkles.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# How to set up signed webhooks

> Verify a Sparkles webhook endpoint, validate every delivery, and handle retries safely.

Sparkles webhooks are the primary event delivery mechanism. Each request contains one event and uses an HMAC-SHA256 signature over the exact request bytes.

## Prerequisites

* An active Sparkles API access grant.
* A public HTTPS endpoint on port `443`.
* A handler that can read the unmodified request body.

Webhook URLs cannot contain credentials, a query string, or a fragment. Their hostname must resolve only to public IP addresses and cannot point back to Sparkles.

## Step 1: Register an endpoint

Open the [API access page](https://sparkles.dev/api), enter your endpoint URL, and copy the `whsec_` signing secret. Sparkles shows the secret only when it is created or rotated.

Keep the endpoint pending until your server implements signature and challenge verification.

## Step 2: Verify every request

Read these headers before parsing the JSON body:

| Header              | Meaning                                                                           |
| ------------------- | --------------------------------------------------------------------------------- |
| `Webhook-Id`        | Stable UUID equal to the event body's `id`. Use it as the idempotency key.        |
| `Webhook-Timestamp` | Unix seconds included in the signature input. Reject values outside five minutes. |
| `Webhook-Signature` | One or more space-separated `v1,BASE64_HMAC` signatures.                          |
| `Webhook-Attempt`   | One-based delivery attempt number. Omitted during endpoint verification.          |

The signed value is:

```text theme={null}
Webhook-Id + "." + Webhook-Timestamp + "." + rawRequestBody
```

This TypeScript verifies the timestamp and accepts any valid signature in constant time:

```typescript theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

const SIGNING_SECRET_PREFIX = 'whsec_';
const TIMESTAMP_TOLERANCE_SECONDS = 5 * 60;

function signingKey(secret: string) {
	if (!secret.startsWith(SIGNING_SECRET_PREFIX)) throw new Error('Invalid signing secret');
	const key = Buffer.from(secret.slice(SIGNING_SECRET_PREFIX.length), 'base64');
	if (key.byteLength !== 32) throw new Error('Invalid signing secret');
	return key;
}

export function verifySparklesWebhook(input: {
	secret: string;
	webhookId: string;
	timestamp: string;
	signature: string;
	rawBody: string;
	nowSeconds?: number;
}) {
	const timestamp = Number(input.timestamp);
	const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1000);
	if (
		!Number.isInteger(timestamp) ||
		Math.abs(nowSeconds - timestamp) > TIMESTAMP_TOLERANCE_SECONDS
	) {
		throw new Error('Stale webhook');
	}

	const signed = `${input.webhookId}.${timestamp}.${input.rawBody}`;
	const expected = createHmac('sha256', signingKey(input.secret)).update(signed).digest();
	const valid = input.signature.split(/\s+/).some((value) => {
		if (!value.startsWith('v1,')) return false;
		const received = Buffer.from(value.slice(3), 'base64');
		return received.byteLength === expected.byteLength && timingSafeEqual(received, expected);
	});
	if (!valid) throw new Error('Invalid webhook signature');
}
```

During signing-secret rotation, Sparkles sends both the old and new signatures for 24 hours. Accept either valid `v1` value, then remove the old secret after the overlap ends.

## Step 3: Answer endpoint verification

Selecting **Verify endpoint** sends a signed `endpoint.verification` request:

```json theme={null}
{
	"id": "f78c1f7e-dc41-4302-880b-97283679c30b",
	"type": "endpoint.verification",
	"data": {
		"challenge": "jQvWIfGqvJX_q4XJ-Qs5vC31NBBvAJXLw5NR5oBTZ4U"
	},
	"createdAt": "2026-08-03T10:10:00.000Z"
}
```

Verify its normal request signature, then add this response function to the same module:

```typescript theme={null}
export function verificationResponse(secret: string, challenge: string) {
	const response = createHmac('sha256', signingKey(secret))
		.update(`sparkles.webhook.verify.${challenge}`)
		.digest('base64');
	return JSON.stringify({ response });
}
```

Verification requests do not contain `sequence` or `Webhook-Attempt`.

## Step 4: Acknowledge durable acceptance

Return any `2xx` response only after saving the event durably. Delivery is at least once, so deduplicate on the body UUID before applying side effects.

Sparkles retries transient failures after approximately 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, 1 day, 2 days, and 3 days. An HTTP `429` response may override the next delay through `Retry-After`, capped at one day. Return HTTP `410` to disable the endpoint and stop delivery.

## Verification

The API access page marks the endpoint active after it receives a valid challenge response. A delivered event should have a matching body `id` and `Webhook-Id`, a current timestamp, and a valid signature.

## Troubleshooting

* Signature mismatch: verify against the raw bytes before JSON parsing or reserialization.
* Verification timeout: respond within 15 seconds with no more than 4 KiB of JSON.
* Endpoint rejected: use public HTTPS on port 443 without a query string or fragment.
* Duplicate event: acknowledge it without applying its side effects again.
