Receive webhooks
Add an HTTPS endpoint, check the signature of every delivery, answer within 10 seconds, and handle repeats and order.
What you'll build
An endpoint on your server that receives every change to your projects, listings and reports as it happens. It checks each delivery's signature, answers within 10 seconds, and acts on each event once, even when a delivery comes twice or out of order.
Before you start
- A key. Live and test keys have their own endpoints; build with a test key first.
- A public HTTPS URL that reaches your server. Deliveries do not follow redirects, so use the final URL.
- Node 20 or later, or Python 3 with Flask, for the example endpoints.
1. Add the endpoint
Register the URL, and the events it should receive: types such as listing.live, groups such as listing.*, or ["*"] for all.
curl https://api.submitator.com/v1/webhook_endpoints \
-H "Authorization: Bearer $SUBMITATOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.northlight.test/listings", "events": ["listing.*", "project.*"], "description": "Northlight CRM sync"}'{
"object": "webhook_endpoint",
"id": "we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT",
"livemode": false,
"url": "https://hooks.northlight.test/listings",
"description": "Northlight CRM sync",
"events": ["listing.*", "project.*"],
"enabled": true,
"secret": "whsec_9laRgOMT0C3d8Je6FbArCUY0bGJM+r2emaNUGJCH+aw=",
"created_at": "2026-10-06T12:00:04Z"
}The secret is shown in this response only: store it as SUBMITATOR_WEBHOOK_SECRET. The endpoint receives the events recorded after this moment, not the ones before.
2. Run the endpoint
The endpoint reads the raw body, checks the signature with the official Standard Webhooks library, skips an event it has handled, and answers 204. A delivery that fails the check gets 400.
import { Webhook } from "standardwebhooks";
// The whsec_ secret of your endpoint, shown once when you added it.
const secret = process.env.SUBMITATOR_WEBHOOK_SECRET;
// Returns the event when the delivery is ours. Throws a
// WebhookVerificationError when the signature does not match or the
// timestamp is more than 5 minutes from your clock.
export function verifyWebhook(rawBody, headers, whsec = secret) {
return new Webhook(whsec).verify(rawBody, headers);
}
import { createServer } from "node:http";
import { fileURLToPath } from "node:url";
// Event ids you have handled. Keep them in your database: a delivery can come
// more than once, always with the same webhook-id.
const handled = new Set();
export async function handleDelivery(request, response) {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const rawBody = Buffer.concat(chunks).toString("utf8");
let event;
try {
event = verifyWebhook(rawBody, request.headers);
} catch {
response.writeHead(400).end();
return;
}
// Answer within 10 seconds: queue slow work instead of doing it here.
if (!handled.has(event.id)) {
handled.add(event.id);
console.log(event.type, event.data.object.id, "was", event.data.previous_status);
}
response.writeHead(204).end();
}
// `node verify-webhook.mjs` receives deliveries on port 3000.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
createServer(handleDelivery).listen(3000);
}Keep the handled ids in your database, not in memory, so a restart does not handle an event twice. Signatures has the check in Ruby, PHP and Go.
3. Send a test event
POST /webhook_endpoints/{webhook_endpoint_id}/test sends a ping event to the endpoint now, whatever its events filter. Your endpoint should log ping, the endpoint's id and null.
curl -X POST https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/test \
-H "Authorization: Bearer $SUBMITATOR_API_KEY"{
"object": "webhook_delivery",
"id": "wd_0Tp4Kx9Wq2Lt7Vb3Rz8NmF",
"livemode": false,
"endpoint": "we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT",
"event": "evt_7Aa2Kq8Wx3Lt9Vb5Rz1NdM",
"event_type": "ping",
"status": "pending",
"attempts": 0,
"next_attempt_at": "2026-10-06T12:01:15Z",
"last_attempt": null,
"created_at": "2026-10-06T12:01:15Z"
}4. Check the delivery
Each delivery records its attempts. Read them to see what your endpoint answered and how long it took.
curl "https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/deliveries?limit=1" \
-H "Authorization: Bearer $SUBMITATOR_API_KEY"{
"object": "webhook_delivery",
"id": "wd_0Tp4Kx9Wq2Lt7Vb3Rz8NmF",
"event_type": "ping",
"status": "succeeded",
"attempts": 1,
"last_attempt": { "attempted_at": "2026-10-06T12:01:15Z", "http_status": 204, "duration_ms": 182, "error": null }
}A last_attempt.http_status of 400 means your endpoint rejected the signature: check that it reads the raw body and the right secret.
5. Act on the events
type says what happened and data.object holds the project, listing or report right after it. Deliveries can arrive out of order, so compare data.previous_status with the status you stored before you act.
When type is | A common action |
|---|---|
listing.live | Store data.object.live_url and tell your client. |
listing.action_required, project.action_required | Forward client_message when action.waiting_on is client, or act when it is agency. |
listing.not_accepted | Show data.object.reason; its place in the allowance comes back. |
report.ready | Download the file before data.object.expires_at. |
| A type you do not know | Answer 2xx and ignore it: new types can appear. |
Event types lists all 15 and what each data holds.
6. Change what it receives
PATCH /webhook_endpoints/{webhook_endpoint_id} changes the URL, the events list or the description, or disables the endpoint with enabled false. A new events list replaces the old one.
curl -X PATCH https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT \
-H "Authorization: Bearer $SUBMITATOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"events": ["listing.live", "listing.not_accepted", "project.*"]}'Check it worked
- Your endpoint logged the
pingwith the endpoint's id. GET /webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcTshowsdelivery_summary.last_http_status204.- In test mode, a listing you move with
POST /test_helpers/listings/{listing_id}/advancereaches your endpoint within about a minute.
What can go wrong
| Code | Status | Fix |
|---|---|---|
endpoint_url_invalid | 422 | The URL is not a public https URL, or holds a user name or password. |
validation_failed | 422 | An events value is not a type or a group, such as listing.published. |
quota_exceeded | 422 | Before your first purchase, test mode allows 3 endpoints. Delete one first. |
Deliveries that time out or get a status other than 2xx are tried again: Retries and failures has the schedule and the rule that disables an endpoint.
Next steps
- Keep your data in sync: webhooks and the events feed together.
- Signatures: the check in other languages, and rolling the secret.
- Go-live checklist: your live endpoint before the first real launch.