# Webhooks

> When an asynchronous check ends, we post its result to your callback URL, signed with your account’s secret in the Standard Webhooks format. We retry for about 22 hours until your endpoint answers with a 2xx.

Base URL: `https://checksharecode.co.uk` · Auth: `authorization: Bearer <key>` · Web version: https://checksharecode.co.uk/docs/webhooks

## Where we send it

- **The request’s callback URL.** The `callback_url` you sent to [POST /api/check/async](https://checksharecode.co.uk/docs/api/check-async.md), if any. Handy for testing, or for sending checks to different systems.
- **Otherwise your default URL** for that mode, set on [Webhooks](https://checksharecode.co.uk/app/webhooks) in the dashboard.
- **Otherwise nowhere.** The check is poll only, through `GET /api/check/async/:id`.

The URL is fixed when the check is queued, so changing your default later doesn’t move checks already queued. It must use `https`, be at most 2048 characters, have no user name or password, and not point at a private or internal address. We check that when you give it (`400 INVALID_INPUT`) and again before every attempt. You can put your own id in the query string, like `?applicant=4411`, but never the applicant’s name, share code or date of birth.

## What we send

The request your endpoint receives:

```http
POST /hooks/rtw?applicant=4411 HTTP/1.1
Host: api.acme.example
Content-Type: application/json
User-Agent: CheckShareCode-Webhooks/1
webhook-id: evt_5f2a8c1e9b7d4a6f8e3c2b1a0d9f8e7c
webhook-timestamp: 1790589614
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{
  "type": "check.succeeded",
  "created_at": "2026-09-28T10:00:14.000Z",
  "data": { …the same object GET /api/check/async/:id returns… }
}
```

| type | When |
| --- | --- |
| check.succeeded | The check ended with a result. `data.result` holds it. |
| check.failed | The check ended with an error, after any retries. `data.error.code` says which. |
| check.test | You pressed Send test event on the dashboard. `data` is a fixed sandbox result. |

`data` is exactly what `GET /api/check/async/:id` returns at that moment. On a live check it includes the applicant’s photo and the gov.uk PDF, so don’t set your body size limit too low. Each check sends exactly one event, so the order they arrive in doesn’t matter.

## Verify the signature

Every webhook is signed with your account’s secret, a `whsec_…` string you can reveal on [Webhooks](https://checksharecode.co.uk/app/webhooks). There is one for live and one for sandbox, and the same secret signs every URL. The signature follows [Standard Webhooks](https://www.standardwebhooks.com), which has a library for most languages: install `standardwebhooks` from npm or PyPI. It checks the signature and refuses a timestamp more than 5 minutes old, so a captured request can’t be replayed.

Node.js (Express):

```js
import express from "express";
import { Webhook } from "standardwebhooks";

// whsec_... from /app/webhooks. Sandbox and live each have their own.
const wh = new Webhook(process.env.CHECKSHARECODE_WEBHOOK_SECRET);
const app = express();

// The signature covers the exact bytes we sent, so read the raw body.
app.post("/hooks/rtw", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body, req.headers);
  } catch {
    return res.status(400).send("bad signature");
  }
  // Retries reuse webhook-id: skip an event you've already handled.
  const check = event.data;
  if (event.type === "check.succeeded") {
    // check.result has the same fields POST /api/check returns.
  } else if (event.type === "check.failed") {
    // check.error.code is one of the POST /api/check error codes.
  }
  res.sendStatus(204);
});

app.listen(3000);
```

Python (Flask):

```python
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook

# whsec_... from /app/webhooks. Sandbox and live each have their own.
wh = Webhook(os.environ["CHECKSHARECODE_WEBHOOK_SECRET"])
app = Flask(__name__)

@app.post("/hooks/rtw")
def rtw_webhook():
    # The signature covers the exact bytes we sent, so read the raw body.
    try:
        event = wh.verify(request.get_data(), request.headers)
    except Exception:
        return "bad signature", 400
    # Retries reuse webhook-id: skip an event you've already handled.
    check = event["data"]
    if event["type"] == "check.succeeded":
        pass  # check["result"] has the same fields POST /api/check returns.
    elif event["type"] == "check.failed":
        pass  # check["error"]["code"] is one of the POST /api/check error codes.
    return "", 204
```

Without a library: the signature is `v1,` then the base64 HMAC-SHA256 of `webhook-id.webhook-timestamp.body`, keyed with the base64-decoded part of the secret after `whsec_`. Compare it in constant time.

## Answering, and retries

- **Answer with any 2xx within 10 seconds.** Anything else, a timeout, or a redirect counts as a failure. We don’t follow redirects. If you have slow work to do, save the event and answer first.
- **We try 8 times in all**: once straight away, then after 30 seconds, 2 minutes, 10 minutes, 1 hour, 3 hours, 6 hours and 12 hours: about 22 hours from first to last.
- **Retries share one id.** Every attempt has the same `webhook-id`. Delivery is at least once, so keep the ids you have handled and skip repeats.
- **If all 8 fail**, the delivery shows as given up on the dashboard and we email you, at most once a day for each of live and sandbox. Until we delete the result, 7 days after the check, you can resend it from the dashboard or fetch it with `GET`.

## Test your endpoint

- **Send test event** on [Webhooks](https://checksharecode.co.uk/app/webhooks) posts a signed `check.test` to your default URL, or to one you type in. It is tried once, and the result shows straight away.
- **A sandbox check** with a `callback_url` goes through the same flow as a live one, signed with your sandbox secret. Use the [test codes](https://checksharecode.co.uk/docs/quickstart.md#test-codes) to get a `check.failed` too.

## The Webhooks page

On [Webhooks](https://checksharecode.co.uk/app/webhooks), for live and sandbox separately: set a default URL, reveal or rotate the secret, send a test event, and see your last 50 deliveries with each response code. Rotating the secret takes effect at once, retries included, so update your endpoint straight after. We keep the delivery log for 7 days and never store the bodies we send.

See also: [POST /api/check/async](https://checksharecode.co.uk/docs/api/check-async.md) · [Build with an agent](https://checksharecode.co.uk/docs/agents.md)
