# Receive webhooks

URL: https://submitator.com/docs/guides/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.

```bash
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"}'
```

```js
const res = await fetch("https://api.submitator.com/v1/webhook_endpoints", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SUBMITATOR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://hooks.northlight.test/listings",
    events: ["listing.*", "project.*"],
    description: "Northlight CRM sync",
  }),
});
console.log(await res.json());
```

```python
import os
import requests

res = requests.post(
    "https://api.submitator.com/v1/webhook_endpoints",
    headers={"Authorization": f"Bearer {os.environ['SUBMITATOR_API_KEY']}"},
    json={
        "url": "https://hooks.northlight.test/listings",
        "events": ["listing.*", "project.*"],
        "description": "Northlight CRM sync",
    },
)
print(res.json())
```

Response 201 (some fields left out):

```json
{
  "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.

verify-webhook.mjs:

```js
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);
}
```

app.py:

```python
# pip install flask standardwebhooks
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["SUBMITATOR_WEBHOOK_SECRET"])
# Event ids you have handled. Keep them in your database: a delivery can
# come more than once, always with the same webhook-id.
handled = set()

@app.post("/listings")
def receive():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError:
        return "", 400
    # Answer within 10 seconds: queue slow work instead of doing it here.
    if event["id"] not in handled:
        handled.add(event["id"])
        print(event["type"], event["data"]["object"]["id"], "was", event["data"]["previous_status"])
    return "", 204
```

Keep the handled ids in your database, not in memory, so a restart does not handle an event twice. [Signatures](https://submitator.com/docs/webhooks/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`.

```bash
curl -X POST https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/test \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY"
```

```js
const res = await fetch(
  "https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/test",
  { method: "POST", headers: { Authorization: `Bearer ${process.env.SUBMITATOR_API_KEY}` } },
);
console.log(await res.json());
```

```python
import os
import requests

res = requests.post(
    "https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/test",
    headers={"Authorization": f"Bearer {os.environ['SUBMITATOR_API_KEY']}"},
)
print(res.json())
```

Response 202:

```json
{
  "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.

```bash
curl "https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/deliveries?limit=1" \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY"
```

```js
const res = await fetch(
  "https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/deliveries?limit=1",
  { headers: { Authorization: `Bearer ${process.env.SUBMITATOR_API_KEY}` } },
);
console.log(await res.json());
```

```python
import os
import requests

res = requests.get(
    "https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/deliveries",
    headers={"Authorization": f"Bearer {os.environ['SUBMITATOR_API_KEY']}"},
    params={"limit": 1},
)
print(res.json())
```

One delivery from the response:

```json
{
  "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](https://submitator.com/docs/webhooks/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.

```bash
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 `ping` with the endpoint's id.
- `GET /webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT` shows `delivery_summary.last_http_status` `204`.
- In test mode, a listing you move with `POST /test_helpers/listings/{listing_id}/advance` reaches your endpoint within about a minute.

## What can go wrong

| Code | Status | Fix |
| --- | --- | --- |
| [`endpoint_url_invalid`](https://submitator.com/docs/errors#endpoint_url_invalid) | 422 | The URL is not a public `https` URL, or holds a user name or password. |
| [`validation_failed`](https://submitator.com/docs/errors#validation_failed) | 422 | An `events` value is not a type or a group, such as `listing.published`. |
| [`quota_exceeded`](https://submitator.com/docs/errors#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](https://submitator.com/docs/webhooks/retries-and-failures) has the schedule and the rule that disables an endpoint.

## Next steps

- [Keep your data in sync](https://submitator.com/docs/guides/keep-your-data-in-sync): webhooks and the events feed together.
- [Signatures](https://submitator.com/docs/webhooks/signatures): the check in other languages, and rolling the secret.
- [Go-live checklist](https://submitator.com/docs/guides/go-live-checklist): your live endpoint before the first real launch.
