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.
Where we send it
- The request’s callback URL. The
callback_urlyou sent to POST /api/check/async, if any. Handy for testing, or for sending checks to different systems. - Otherwise your default URL for that mode, set on 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
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. There is one for live and one for sandbox, and the same secret signs every URL. The signature follows Standard Webhooks, 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.
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);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 "", 204Without 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 posts a signed
check.testto 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_urlgoes through the same flow as a live one, signed with your sandbox secret. Use the test codes to get acheck.failedtoo.
The Webhooks page
On 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.