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

# Webhook

> Forward every submission, raw and unmodified, to your own HTTPS endpoint.

<Info>
  Requires a plan with the `webhooks_enabled` feature. Configured per-form from that form's **Webhooks** settings — there's no single workspace-wide webhook that fires for every form.
</Info>

Unlike [Slack](/essentials/integrations/slack), [Discord](/essentials/integrations/discord), or [Telegram](/essentials/integrations/telegram) — which get a formatted, human-readable message — a plain webhook forwards Raykoi's full raw event envelope, untouched. Use this one if you're parsing the payload programmatically: a backend endpoint, an n8n/Zapier-style workflow, anything that needs the structured data rather than a chat message.

<Frame>
  <img src="https://mintcdn.com/raykoi/OUWOqoE2CmHs9Dj9/images/dashboard/integrations-overview.png?fit=max&auto=format&n=OUWOqoE2CmHs9Dj9&q=85&s=084cc1aab9d0e9ed6bb3cb87289ec407" alt="Integrations page in the Raykoi dashboard, showing Notification Channels and Developer Webhooks" width="1564" height="620" data-path="images/dashboard/integrations-overview.png" />
</Frame>

## Setting one up

<Steps>
  <Step title="Add a webhook">
    From the form's **Webhooks** settings, choose **Custom**, and enter any HTTPS URL you control.
  </Step>

  <Step title="Set a signing secret (recommended)">
    Optional, but strongly recommended — generated automatically if you don't supply your own. Used to verify a delivery genuinely came from Raykoi, not an attacker who guessed your endpoint URL.
  </Step>

  <Step title="Save, then send a test event">
    Every webhook has a **Send test event** button — fires a real delivery with sample data through the exact same code path a live submission would use, so you can confirm your endpoint and signature verification work before going live.
  </Step>
</Steps>

## The event

Currently one event type: `submission.created`, fired after a submission is successfully stored (never for a blocked or rate-limited attempt).

```json theme={null}
{
  "event": "submission.created",
  "form": { "id": "YOUR_PUBLIC_ID", "name": "Contact form" },
  "submission": {
    "id": "sub_pub_1a2b3c",
    "submission_number": 42,
    "data": { "email": "user@example.com", "name": "Alice" },
    "created_at": "2026-08-23T18:00:00.000Z"
  },
  "delivery_id": "del_9f8e7d"
}
```

`delivery_id` is stable across retries of the *same* delivery attempt — use it to deduplicate on your end, since a retried delivery after a timeout can occasionally result in your endpoint receiving the same event twice (see [Retries](#retries) below).

## Headers

<ResponseField name="X-Raykoi-Delivery-Id" type="string">
  Sent on every request, signed or not — the one reliable dedupe key.
</ResponseField>

<ResponseField name="X-Raykoi-Event" type="string">
  The event type, e.g. `submission.created`. Only sent when the webhook has signing configured.
</ResponseField>

<ResponseField name="X-Raykoi-Timestamp" type="string">
  Unix timestamp (seconds) the request was signed at. Only sent when signing is configured.
</ResponseField>

<ResponseField name="X-Raykoi-Signature" type="string">
  `sha256=<hex>` — HMAC-SHA256 over `${timestamp}.${rawBody}`, using the secret shown to you once at creation. Only sent when signing is configured.
</ResponseField>

## Verifying the signature

Recompute the HMAC over the *exact* timestamp and raw body you received, and compare with a timing-safe function — never a plain `===`, which leaks timing information an attacker can use to guess the correct signature byte-by-byte.

```javascript theme={null}
import crypto from 'crypto';

function verifyWebhook(secret, timestamp, rawBody, signatureHeader) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  const received = signatureHeader.replace('sha256=', '');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}
```

Binding the timestamp into the signature (not just the body) blocks replay of a captured, valid request — reject anything with a timestamp too far in the past on your end.

<Warning>
  Verify against the *raw* request body, not a re-parsed/re-serialized one — JSON re-serialization can reorder keys or change whitespace, producing a different signature than what Raykoi actually signed. See [Troubleshooting](/troubleshooting#my-webhook-signature-verification-always-fails) for the exact framework gotcha this causes.
</Warning>

## Retries

A failed delivery (non-2xx response, timeout, or connection error) retries with exponential backoff — **up to 6 attempts total**, spaced 1 minute → 5 minutes → 30 minutes → 2 hours → 6 hours apart. After the 6th attempt fails, the delivery is abandoned rather than retried forever.

A webhook that fails repeatedly is automatically disabled, and the workspace owner is emailed — a permanently-broken endpoint doesn't silently accumulate failed deliveries indefinitely. Re-enable it from the form's Webhooks settings once you've fixed the receiving end.

This retry/disable behavior is identical for Slack, Discord, and Telegram — it's the same delivery pipeline underneath, just a different destination.
