> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.partnerpage.io/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.partnerpage.io/_mcp/server.

# Verify and handle webhook deliveries

This guide is for the person building the receiving end of a PartnerPage webhook. It applies to both kinds: directory webhooks, set up by the company that runs a directory, and the partner lead webhook, set up by a partner. Both send the same request with the same headers.

Setting a webhook up in the dashboard is covered in the Help Center: [directory webhooks](https://support.partnerpage.io/en/articles/17220171) and the [partner lead webhook](https://support.partnerpage.io/en/articles/17340351).

## The request we send

Every delivery is an HTTPS `POST` to your URL with a JSON body encoded as UTF-8. It is sent from PartnerPage's servers within seconds of the event.

| Header                  | Value                                                                                         |
| ----------------------- | --------------------------------------------------------------------------------------------- |
| `Content-Type`          | `application/json; charset=utf-8`                                                             |
| `X-Event-Type`          | The event name, for example `contact_request_created`                                         |
| `X-Webhook-Signature`   | HMAC-SHA256 of the raw request body, hex encoded in lowercase, keyed with your signing secret |
| `X-Webhook-Delivery-Id` | An id for this delivery. If the same delivery is ever sent again, it carries the same id      |

Conventions used in every payload:

* Ids are UUIDs, sent as strings.
* `created` timestamps are in UTC, formatted `YYYY-MM-DD HH:MM`.
* Fields with no value are `null`.
* We may add fields over time. Ignore fields you do not recognise instead of rejecting the request.

## Verify the signature

Checking the signature proves the request came from PartnerPage and was not changed on the way.

**Where to find your signing secret**

* Directory webhooks: your directory, then **Webhooks**, then **Events**, on the **Signing secret** card. Each directory has its own secret.
* Partner lead webhook: **Settings**, then **Developers**, on the **Lead webhook** card. Each organization has its own secret.

**How to check it**

1. Read the raw bytes of the request body, before any JSON parsing.
2. Compute the HMAC-SHA256 of those bytes, using your signing secret as the key, and encode the result as lowercase hex.
3. Compare it with the `X-Webhook-Signature` header using a constant-time comparison.
4. Reject the request if they differ.

Python:

```python
import hashlib
import hmac

def is_valid(raw_body: bytes, header_signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_signature)
```

Node.js:

```js
const crypto = require("crypto");

function isValid(rawBody, headerSignature, secret) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return (
    expected.length === headerSignature.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(headerSignature))
  );
}
```

In frameworks that parse JSON automatically (Express with `express.json()`, Django REST Framework, Rails), sign the original body bytes, not a re-serialised version of the parsed object.

**Rotating the secret.** Clicking **Regenerate** in the dashboard replaces the secret immediately. There is no overlap period: the next delivery is signed with the new secret, so update your receiver right after regenerating.

**Directory showing "Not in use yet".** A directory that used webhooks before per-directory secrets existed is still signed with an older shared secret until someone clicks **Regenerate**. Signatures will not match the secret on the card until then.

**Also use a URL nobody can guess.** Include a long random token in the path of your URL and reject requests without it. n8n, Zapier and Make webhook URLs already include one.

## Reply to a delivery

* Reply with any `2xx` status within **15 seconds**.
* The body of your reply is ignored. Its first 255 characters are stored in the delivery log, which helps when debugging.
* Do slow work after replying: accept the request, queue it, return `200`.
* Point us at the final address. The partner lead webhook does not follow redirects.

## Duplicate deliveries

We make sure an event is delivered even if our servers restart at the moment it is being sent. The trade-off is that, rarely, the same delivery can reach you twice. To handle that:

1. Read `X-Webhook-Delivery-Id` on every request.
2. If you have already processed that id, reply `200` and do nothing else.
3. Otherwise process the event and remember the id. Keeping ids for 24 hours is plenty.

In n8n, Zapier or Make, store the id (a data table, a sheet, Redis) and add an "already seen?" check as the first step of the flow.

## Failed deliveries

A delivery that times out, cannot connect, or receives a status outside `2xx` is recorded as failed and is **not retried**.

Where to see deliveries:

* Directory webhooks: **Webhooks**, then **Logs**. Use the errors filter to show only failures.
* Partner lead webhook: the **Deliveries** card under **Settings**, then **Developers**. Use the **Errors** view.

Directory webhook logs keep all attempts from the last 30 days, plus the latest 100 attempts per existing webhook regardless of age. Logs for deleted webhooks are kept for 30 days only. Recover or save failed payloads before their logs expire.

For directory webhooks, a failure without an answer has `response` and `response_text` set to `null`. Check `outcome` and `failure_reason` instead:

| Outcome            | Meaning                                                              |
| ------------------ | -------------------------------------------------------------------- |
| `timeout`          | Your endpoint did not answer within 15 seconds                       |
| `connection_error` | We could not connect (DNS, TLS or network error)                     |
| `error`            | Another error while sending, including a refused private destination |

For the partner lead webhook, failures without an answer are recorded with a synthetic status and the reason in the response text:

| Recorded status | Meaning                                                                                  |
| --------------- | ---------------------------------------------------------------------------------------- |
| `408`           | Your endpoint did not answer within 15 seconds                                           |
| `503`           | We could not connect (DNS, TLS or network error)                                         |
| `403`           | We refused to send to a destination that is not public; the text starts with `Not sent:` |
| `500`           | Another error while sending                                                              |

When your endpoint answers, both kinds of webhook log its actual HTTP status. Directory logs have outcome `success` for `2xx` and `http_error` otherwise.

## Order of events

Events for the same record can arrive close together, for example a `contact_request_created` followed by a `contact_request_updated`. Do not assume they arrive in order. Each payload describes the record when it was serialized, so a delayed event can contain older `state` and `project_status` values.

If your integration maintains the current lead state, fetch `GET /api/directory/private/v1alpha/contact-requests/{contactId}/` using `contact_request.id` from the payload as `contactId` and an API key authorized to read that directory's contact requests. Use the API response to update your stored state. See [Authentication](/get-started/authentication) for the API base URL and key header.

## Troubleshooting

* **No delivery recorded at all.** For directory webhooks, check that a subscription exists for that event trigger and that the action happened in this directory. For the partner lead webhook, check that the webhook is switched on; if it is, the directory owner may have switched partner lead delivery off for that directory.
* **Timeout (`timeout` or partner `408`).** Reply first, process later.
* **Connection error (`connection_error` or partner `503`).** The URL must resolve publicly and present a valid TLS certificate. Localhost, private networks and self-signed certificates do not work. Use a tunnel such as ngrok while developing.
* **Private destination refused (`error` or partner `403` with `Not sent:`).** No request was sent. Update the URL to resolve to a public internet address.
* **401 or 403 returned by your endpoint.** Check whether your endpoint requires authentication or denies the request. We send no authentication header. Put a token in the URL and verify the signature instead.
* **404 or 405.** Check the path and that the endpoint accepts `POST`.
* **Signature does not match.** Make sure you sign the raw body, that the secret was copied in full, and that nobody regenerated it since. For a directory, check whether the Signing secret card says "Not in use yet".

## Payloads

* Directory webhooks: [Directory webhook events and payloads](/webhooks/directory-webhook-events-and-payloads) lists the ten events and every field.
* Partner lead webhook: [Partner lead webhook event](/webhooks/partner-lead-webhook-event) shows the one payload it sends.