All guides

Guide

Webhooks

We POST to your HTTPS endpoint when something happens, signed so you can prove it came from us.

Events

EventSent whenPersonal data
candidate.application.clickedA resident clicks through to apply for one of your opportunities.Yes: candidate_id, when the resident was signed in.
candidate.application.submittedA resident applies to one of your opportunities.Yes: candidate_id.
candidate.outcome.recordedA verified hire or placement is recorded against an obligation you deliver.Yes: candidate_id.
scheme.opportunity.publishedYour organisation publishes a new opportunity.No.

Payloads identify residents only by our pseudonymous candidate_id, never by name or contact details. It does not name anyone, but it points at one person's record, so treat it as personal data: store it securely and only for as long as you need it. The exact fields for each event are in the API reference under Webhooks.

Set up in your dashboard

If your organisation is a verified employer, developer or training provider, sign in at https://opportunityplatform.co.uk and open API access, then the Webhooks tab. There you can:

  • Add an endpoint. Enter your HTTPS address and tick the events you want. The signing secret is shown once, with code to verify signatures.
  • Send a test event. We sign and send a ping event straight away and show you the HTTP status your endpoint answered with and how long it took. Up to 5 a minute per endpoint and 10 a minute across your organisation; a test is never retried.
  • Read the delivery log. The last 50 deliveries to each endpoint: event, when it was sent, attempt number, HTTP status, time taken, the next retry and the start of your response. A failed delivery can be sent again from there.
  • Rotate the signing secret (see below), disable and enable an endpoint, or delete it. Deleted endpoints stay listed for your records.

Up to 20 endpoints per organisation, and up to 20 new ones a day. If your verification lapses you can still see, disable and delete your endpoints, but not add or re-enable them.

Events with personal data need approval

Anyone in a verified organisation can subscribe to scheme.opportunity.published. The three candidate.* events need our approval first, however you set them up: request the webhooks:manage scope on one of your API keys under API access, with a short note of what you will use the data for. Once we approve it, your organisation can subscribe to them in the dashboard and by API. Until then those checkboxes are locked, and an API request for them returns 403 forbidden_scope.

The approval is checked again whenever that data would leave us. If it ends (the approved key is suspended, revoked or expires), candidate.* events stop at once: new ones are not queued, ones already queued are not sent, GET /v1/events stops listing them, and you cannot resend them or move those endpoints to a new address until it is approved again. Your other events carry on.

Set up by API

/v1/webhooks/endpoints manages the same endpoints, with the same rules and limits. Call it with an API key holding webhooks:manage (see Authentication), or from a signed-in session of a verified organisation.

POST https://opportunityplatform.co.uk/api/v1/webhooks/endpoints
curl -s -X POST "https://opportunityplatform.co.uk/api/v1/webhooks/endpoints" \
  -H "Authorization: Bearer $OP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/opportunity-platform/webhooks",
    "enabled_events": ["candidate.application.submitted", "scheme.opportunity.published"]
  }'

The response contains signing_secret. It is shown once. Store it as a secret; if you lose it, rotate it from the dashboard. GET on the same address lists your endpoints, and /v1/webhooks/endpoints/<id> takes PATCH to change the URL or events or pause the endpoint, and DELETE to remove it. Deleting drops any deliveries still waiting to be sent to it. Going over 20 endpoints is 409 conflict; over 20 new endpoints in a day is 429 rate_limited, with a Retry-After header counting down to midnight UTC, when the daily allowance resets.

The URL must be HTTPS, on the standard port (443) or 8443, and reachable from the internet. Private, loopback, link-local and internal addresses, and names that resolve to them, are refused when you save the endpoint and checked again before every delivery. We read at most the first 4 KB of your reply.

What a delivery looks like

Headers
POST /opportunity-platform/webhooks
Content-Type: application/json
OP-Signature: t=1759395600,v1=6c3e...e91f
OP-Event-Id: 30000000-0000-4000-8000-000000000002
OP-Event-Type: candidate.application.submitted
OP-Delivery-Attempt: 1
Body
{
  "id": "30000000-0000-4000-8000-000000000002",
  "type": "candidate.application.submitted",
  "created": 1759395600,
  "livemode": true,
  "data": {
    "object": {
      "application_id": "30000000-0000-4000-8000-0000000000a1",
      "opportunity_id": "3f1a9c2e-5b41-4a7e-9c3d-1e2f4a6b8c0d",
      "candidate_id": "30000000-0000-4000-8000-0000000000d1",
      "source": "platform",
      "applied_at": "2026-10-02T09:00:00.000Z"
    }
  },
  "idempotency_key": "app_30000000-0000-4000-8000-0000000000a1"
}

livemode is currently true from the sandbox as well as production, so do not use it to tell them apart. Each environment has its own endpoints and secrets.

Verify the signature

OP-Signature is t=<unix seconds>,v1=<hex>. The hex is HMAC-SHA256 over the string <t>.<raw body>, keyed with your signing secret exactly as we gave it to you (use the characters of the secret as the key; do not hex-decode it). To verify:

  1. Read the raw request body before any JSON parsing. Re-serialised JSON will not match.
  2. Split the header on commas; take t and every v1 value.
  3. Reject the request if t is more than 300 seconds from your clock. This stops replays.
  4. Compute the HMAC and compare it to each v1 in constant time. Accept if any matches.
Node.js
const crypto = require('node:crypto');

/**
 * header:  the OP-Signature request header
 * rawBody: the request body exactly as received (Buffer or string),
 *          before any JSON parsing
 * secret:  the signing_secret you were shown when creating the endpoint
 */
function verifyOpSignature(header, rawBody, secret, toleranceSeconds = 300) {
  if (!header || !secret) return false;

  let t = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i < 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't' && Number.isFinite(Number(value))) t = Number(value);
    else if (key === 'v1') signatures.push(value);
  }
  if (t === null || signatures.length === 0) return false;

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - t) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac('sha256', secret)          // the secret string itself is the key
    .update(`${t}.`)
    .update(rawBody)
    .digest();

  // Several v1= values appear while a secret is being rotated: accept any match.
  return signatures.some((hex) => {
    const given = Buffer.from(hex, 'hex');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}
Node.js, with Express
const express = require('express');
const app = express();

// Raw body: the signature is over the exact bytes we sent.
app.post('/opportunity-platform/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyOpSignature(req.get('OP-Signature'), req.body, process.env.OP_WEBHOOK_SECRET);
  if (!ok) return res.status(400).send('bad signature');

  const event = JSON.parse(req.body.toString('utf8'));
  // OP-Event-Id is the same on every retry: skip ids you have already handled.
  res.sendStatus(200);   // reply fast; do slow work after responding
  handleEvent(event);
});
Python 3
import hashlib
import hmac
import time


def verify_op_signature(header: str, raw_body: bytes, secret: str,
                        tolerance_seconds: int = 300) -> bool:
    """header: the OP-Signature request header.
    raw_body: the request body exactly as received, before JSON parsing.
    secret: the signing_secret you were shown when creating the endpoint."""
    if not header or not secret:
        return False

    t = None
    signatures = []
    for part in header.split(","):
        key, sep, value = part.partition("=")
        if not sep:
            continue
        key, value = key.strip(), value.strip()
        if key == "t":
            try:
                t = int(value)
            except ValueError:
                pass
        elif key == "v1":
            signatures.append(value.lower())
    if t is None or not signatures:
        return False

    if abs(int(time.time()) - t) > tolerance_seconds:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),  # the secret string itself is the key
        f"{t}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()
    # Several v1= values appear while a secret is being rotated: accept any
    # match. Compare bytes: a str comparison raises on non-ASCII input
    # instead of returning False.
    expected_bytes = expected.encode("ascii")
    return any(hmac.compare_digest(expected_bytes, s.encode("utf-8")) for s in signatures)
Python, with Flask
from flask import Flask, request, abort
import os

app = Flask(__name__)


@app.post("/opportunity-platform/webhooks")
def op_webhook():
    raw = request.get_data()  # raw bytes, before any JSON parsing
    if not verify_op_signature(request.headers.get("OP-Signature", ""), raw,
                               os.environ["OP_WEBHOOK_SECRET"]):
        abort(400)
    event = request.get_json()
    # OP-Event-Id is the same on every retry: skip ids you have already handled.
    return "", 200

Ignore any other keys you see in the header; new signature versions may be added alongside v1.

Rotating the signing secret

Rotate from the endpoint in your dashboard. You see the new secret once, and choose:

  • Keep the old secret for 24 hours (recommended). During that window every delivery carries two v1 values, one for each secret, so a receiver checking either accepts it. Switch your system to the new secret any time in those 24 hours. Rotating again inside the window replaces the old secret being kept, so finish one switch before starting another.
  • Stop the old secret now, if it may have leaked. Deliveries fail your signature check until your system uses the new secret.

Retries

Reply with any 2xx within 5 seconds and the delivery is done. Any other status, a timeout or a connection failure is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 24 hours and 24 hours: eight attempts in all, after which that delivery is dropped. If 30 deliveries in a row are dropped, the endpoint is disabled and we email whoever created it, if their account still exists, and our team is told too. Redirects are not followed: point your endpoint at its final URL. Once it is fixed, check it with a test event and enable it again from the dashboard, or with PATCH and {"status": "active"}.

  • Be idempotent. The same event can arrive more than once. OP-Event-Id is identical on every attempt; record the ids you have handled.
  • Do not rely on order. Use created if order matters.
  • Reply first, work later. Queue slow work and return 200 straight away.

Missed something?

GET /v1/events returns the same events, newest first, for the event types your active endpoints subscribe to. Use since to backfill after an outage.

Webhooks | Opportunity Platform Developers