//SCHEMADocs
Console
How-to guides

Receive webhooks

Have //SCHEMA tell your systems when a release goes live, a delivery is processed or a member joins, instead of polling for it. You need a credential with config_write.

Note

Every owner of your organization is emailed when an endpoint is created, or changed so that it receives more than before — a new URL, more event types or catalogs, or turned back on. A webhook sends a standing copy of your events somewhere else, and the people who could stop one should know it exists. If none of your owners has an address that can be emailed, the change is refused.

Create an endpoint#

Choose the event types you want, and a URL that accepts POST over https:// on the default port.

Request
mutation {
  createWebhookEndpoint(input: {
    url: "https://hooks.paperradio.example/schema"
    description: "Storefront sync"
    eventTypes: ["release.available", "release.state_changed"]
  }) {
    secret
    endpoint {
      endpointPk
      enabled
    }
  }
}
Response
{
  "data": {
    "createWebhookEndpoint": {
      "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSwq3c0Jm7ZP4c=",
      "endpoint": {
        "endpointPk": 4,
        "enabled": true
      }
    }
  }
}

Store secret in your secret manager straight away. It is never shown again.

  • catalogPks narrows an endpoint to some of your organization's catalogs, so one receiver can watch production while another watches a sandbox. Leave it out to receive every catalog, including ones you create later.
  • The URL must not be a private, loopback or link-local address, and may not carry a username or password — the signature is how your receiver knows a request is ours.
  • An organization can have up to 10 endpoints.

Verify every delivery#

Each delivery is a POST with a JSON body and three headers, from the Standard Webhooks specification:

POST /schema HTTP/1.1
Host: hooks.paperradio.example
Content-Type: application/json
webhook-id: evt_4ee8793102184b15940831a1e5f3ce54
webhook-timestamp: 1792280788
webhook-signature: v1,wcTZPWuxw1nGOoE/qHAFlbqmGk4VP4PsRI7NCX13zaM=

webhook-signature is an HMAC-SHA256, keyed by your secret, of {webhook-id}.{webhook-timestamp}.{body}. Verify it before acting on anything, and verify it over the body exactly as it arrived — re-serializing parsed JSON changes the bytes and breaks the signature. Reject a timestamp more than a few minutes old even when the signature is valid: that is what stops a captured request being replayed later.

Most languages have a maintained Standard Webhooks library that does all of this in one call. Without one:

import { createHmac, timingSafeEqual } from "node:crypto";

// `rawBody` is the request body exactly as it arrived. Throws if invalid.
export function verifyWebhook(secret, headers, rawBody, now = Date.now() / 1000) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"] ?? "";
  if (!id || !timestamp) throw new Error("missing webhook headers");

  if (Math.abs(now - Number(timestamp)) > 5 * 60) throw new Error("stale webhook");

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();

  // During a secret rotation there are two signatures; either may match.
  for (const sig of signatures.split(" ")) {
    const [version, value] = sig.split(",");
    if (version !== "v1" || !value) continue;
    const given = Buffer.from(value, "base64");
    if (given.length === expected.length && timingSafeEqual(given, expected)) return;
  }
  throw new Error("invalid webhook signature");
}
import base64, hashlib, hmac, time

def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> None:
    """`raw_body` is the body exactly as received. Raises ValueError if invalid."""
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature", "")
    if not msg_id or not timestamp:
        raise ValueError("missing webhook headers")

    if abs(time.time() - int(timestamp)) > 5 * 60:
        raise ValueError("stale webhook")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    # During a secret rotation there are two signatures; either may match.
    for sig in signatures.split(" "):
        version, _, value = sig.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return
    raise ValueError("invalid webhook signature")
// hmac = "0.12", sha2 = "0.10", base64 = "0.22"
use base64::{Engine, engine::general_purpose::STANDARD};
use hmac::{Hmac, Mac};
use sha2::Sha256;

/// `raw_body` is the body exactly as received; `now` is seconds since the epoch.
pub fn verify_webhook(
    secret: &str,
    id: &str,
    timestamp: &str,
    signatures: &str,
    raw_body: &[u8],
    now: i64,
) -> Result<(), &'static str> {
    let ts: i64 = timestamp.parse().map_err(|_| "bad timestamp")?;
    if (now - ts).abs() > 5 * 60 {
        return Err("stale webhook");
    }
    let key = STANDARD
        .decode(secret.trim_start_matches("whsec_"))
        .map_err(|_| "bad secret")?;

    // During a secret rotation there are two signatures; either may match.
    for sig in signatures.split(' ') {
        let Some(("v1", value)) = sig.split_once(',') else { continue };
        let Ok(given) = STANDARD.decode(value) else { continue };
        let mut mac = Hmac::<Sha256>::new_from_slice(&key).expect("any key length");
        mac.update(format!("{id}.{timestamp}.").as_bytes());
        mac.update(raw_body);
        if mac.verify_slice(&given).is_ok() {
            return Ok(());
        }
    }
    Err("invalid webhook signature")
}

Each of these accepts the Standard Webhooks specification's own published example and rejects a body or timestamp altered from it.

Answer quickly, then do the work#

Return any 2xx within 10 seconds and the delivery counts as done. Anything else — another status, a redirect, a timeout — is retried. So acknowledge first and process afterwards: put the event on your own queue and answer 204.

Redirects are never followed. If your URL moves, update the endpoint.

Expect duplicates, and no particular order#

Deliveries are at least once: a retry after a timeout, or a redelivery you ask for, sends an event your receiver may already have handled. Every attempt carries the same webhook-id, so record the ids you have processed and skip one you have seen.

Deliveries are also unordered: a retried event can arrive after a later one. Treat an event as a prompt to look, not as the state itself — fetch the release, message or member through the API and act on what it says now. Everything an event describes is readable through the API before the event is sent.

Request
query ($upc: String!) {
  release(upc: $upc) {
    releasePk
    state
    availableFrom
    title
  }
}
Variables
{
  "upc": "0602445790135"
}
Response
{
  "data": {
    "release": {
      "releasePk": 9182,
      "state": "active",
      "availableFrom": "2026-10-24T00:00:00Z",
      "title": "Paper Radio"
    }
  }
}

Send a test#

sendTestWebhook sends a webhook.test event to one endpoint through the same path as every other event. If it arrives and your verification accepts it, the integration works.

Request
mutation {
  sendTestWebhook(endpointPk: 4)
}
Response
{
  "data": {
    "sendTestWebhook": "evt_4ee8793102184b15940831a1e5f3ce54"
  }
}

Read the delivery log, and redeliver#

Each endpoint keeps 30 days of deliveries: what was sent, how many attempts it took, and what your receiver answered.

Request
query {
  webhookEndpoint(endpointPk: 4) {
    failingSince
    deliveries(first: 10, state: "pending") {
      deliveryPk
      eventType
      attempts
      lastStatus
      lastError
      nextAttemptAt
    }
  }
}
Response
{
  "data": {
    "webhookEndpoint": {
      "failingSince": "2026-10-17T09:14:02Z",
      "deliveries": [
        {
          "deliveryPk": 1182,
          "eventType": "release.available",
          "attempts": 2,
          "lastStatus": 503,
          "lastError": "http 503",
          "nextAttemptAt": "2026-10-17T09:26:40Z"
        }
      ]
    }
  }
}

A failed delivery is retried for about three days, at widening intervals. An endpoint whose every delivery has failed for five days is turned off and your owners are emailed. Once your receiver is fixed, turn the endpoint back on with updateWebhookEndpoint and resend what you need with redeliverWebhook — turning it on does not replay what it missed.

Rotate the secret without downtime#

  1. Call rotateWebhookSecret and store the new secret.
  2. For the next 24 hours every delivery carries two signatures, one from each secret. Deploy the new secret to your receiver at any point in that window; the verification above accepts either signature.
  3. After 24 hours only the new secret signs.

If a secret has leaked, rotate it — and since the leak could also have let someone change the endpoint, check it: your owners would have been emailed about any change that widened it.

Turn an endpoint off, or delete it#

updateWebhookEndpoint(input: { endpointPk: 4, enabled: false }) stops deliveries and fails anything still pending, keeping the endpoint and its log. deleteWebhookEndpoint removes both.