Signed
webhooks.
Send accepted submissions to your own application. Create a lead, start a workflow, or keep a second system in sync—with a signed event you can verify.
1. Add an endpoint
- Open a form in your workspace and choose Set up a webhook.
- Save a public HTTPS URL on port 443. Use a hostname, without a username, password or URL fragment. Redirects are not followed.
- Copy the signing secret into your receiver’s server environment. It is shown once when created or rotated.
- Choose Send test event. Tests work while delivery is paused and contain a synthetic message.
- When your receiver accepts the test, edit the connection and enable delivery.
You can connect one endpoint per form. Tests are limited to one per minute and 100 per day. Only enable delivery to an endpoint you control or are authorized to use.
2. Event payload
Form Koi sends an HTTP POST with a JSON body. A submission.received event is created alongside an accepted submission when its endpoint is enabled. Messages held as spam do not trigger a webhook until restored.
{
"id": "a5a9ea19-50c7-4a40-9ccc-94e511c22337",
"type": "submission.received",
"createdAt": "2026-09-06T12:00:00.000Z",
"data": {
"submissionId": "0d746747-0ebf-4d33-925d-44205e2fb399",
"formId": "a72bf027-d066-4ce7-b84d-1a7aecc125c8",
"formName": "Website contact",
"fields": {
"name": "Alex",
"email": "alex@example.com",
"message": "Let's talk."
},
"receivedAt": "2026-09-06T12:00:00.000Z",
"attachments": []
}
}fields contains your form’s text values; repeated names become arrays. Attachments contain id, name, field, size in bytes and mediaType. File bytes and public download links are not included. Files remain accessible through the signed-in workspace, subject to their retention.
A webhook.test event has the same top-level id, type and createdAt, with formId, formName and a synthetic message in data. It does not create a submission. Treat every field value as untrusted visitor input, even after verifying its signature.
3. Verify signatures
Signatures follow the Standard Webhooks format. Install standardwebhooks on your server and set FORMKOI_WEBHOOK_SECRET to the full whsec_… value. This helper verifies a bounded request body; call it from your HTTP handler.
npm install standardwebhooksimport { Webhook } from "standardwebhooks";
// Run on your server. Keep this secret out of browser code.
const verifier = new Webhook(process.env.FORMKOI_WEBHOOK_SECRET!);
export async function verifiedEvent(request: Request) {
const reader = request.body?.getReader();
if (!reader) throw new Error("Missing body");
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > 512 * 1024) throw new Error("Body too large");
chunks.push(value);
}
} finally {
await reader.cancel();
reader.releaseLock();
}
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
const raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
// Checks the original body, signature and timestamp freshness.
return verifier.verify(raw, {
"webhook-id": request.headers.get("webhook-id") ?? "",
"webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
"webhook-signature": request.headers.get("webhook-signature") ?? "",
});
}Catch verification failures in your handler and return a 400 response. Validate the verified event’s schema and expected form ID before using it. Do not parse and reserialize JSON before verification: the exact original bytes are signed.
After verification, durably record the event ID and enqueue your work in one transaction. Return a 2xx response only after that transaction succeeds. An already-recorded ID should also return 2xx, without repeating the action. The helper above does not implement your database or queue.
The headers are webhook-id (stable event ID), webhook-timestamp (Unix seconds) and webhook-signature (v1, followed by a base64 HMAC-SHA256). The signed input is id.timestamp.rawBody; the key is the base64-decoded secret after removing whsec_. The library checks timestamp freshness to limit replay. Keep your server clock synchronized.
4. Retries and idempotency
Delivery is at least once. A timeout can happen after your server has processed an event, so deduplicate its ID. Every retry uses the same event ID and payload, with a fresh timestamp and signature. Event ordering is not guaranteed.
| Receiver result | What Form Koi does |
|---|---|
| Any 2xx response | Marks the event accepted by your endpoint. |
| 408, 425, 429, 5xx or a network failure | Retries, up to eight attempts per cycle. |
| Redirect, other 4xx or a non-public destination | Stops and marks the event as needing attention. |
Retry delays start at 30 seconds, then 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. A valid Retry-After can extend a delay, capped at 24 hours. Scheduling may add time. Each attempt has a 10-second network budget, including the destination check.
Open delivery history to inspect the event, HTTP status and attempts. Failed events can be manually retried after one minute if their original endpoint configuration is still active. This starts another retry cycle with the same event ID. Delivered, failed and cancelled events become eligible for cleanup 30 days after their last update, together with their attempt history. Pending and in-flight events are kept for recovery. Deleting a message or connection removes its events earlier. Your own receiver controls its retention.
5. Rotate or remove
Saving endpoint settings or rotating its signing secret cancels queued events from the previous configuration. Events already in flight may finish. Old messages are not automatically backfilled when you enable a connection.
To rotate a secret, pause delivery, allow in-flight requests to finish, rotate it, update your receiver’s environment, and send a test. Re-enable delivery after it passes. There is no overlapping old-secret grace period.
Moving a message to spam cancels pending events; restoring it can resume the same event if the endpoint configuration has not changed. Deleting a message or endpoint removes its local delivery records but cannot recall requests that were already sent.
A small connection. A lot of possibilities.
Connect a form