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.
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.
mutation {
createWebhookEndpoint(input: {
url: "https://hooks.paperradio.example/schema"
description: "Storefront sync"
eventTypes: ["release.available", "release.state_changed"]
}) {
secret
endpoint {
endpointPk
enabled
}
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "mutation {\n createWebhookEndpoint(input: {\n url: \"https://hooks.paperradio.example/schema\"\n description: \"Storefront sync\"\n eventTypes: [\"release.available\", \"release.state_changed\"]\n }) {\n secret\n endpoint {\n endpointPk\n enabled\n }\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation {
createWebhookEndpoint(input: {
url: "https://hooks.paperradio.example/schema"
description: "Storefront sync"
eventTypes: ["release.available", "release.state_changed"]
}) {
secret
endpoint {
endpointPk
enabled
}
}
}
`,
}),
});
const { data, errors } = await res.json();{
"data": {
"createWebhookEndpoint": {
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSwq3c0Jm7ZP4c=",
"endpoint": {
"endpointPk": 4,
"enabled": true
}
}
}
}Store secret in your secret manager straight away. It is never shown again.
catalogPksnarrows 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.
query ($upc: String!) {
release(upc: $upc) {
releasePk
state
availableFrom
title
}
}{
"upc": "0602445790135"
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "query ($upc: String!) {\n release(upc: $upc) {\n releasePk\n state\n availableFrom\n title\n }\n}",
"variables": {
"upc": "0602445790135"
}
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
query ($upc: String!) {
release(upc: $upc) {
releasePk
state
availableFrom
title
}
}
`,
variables: {
"upc": "0602445790135"
},
}),
});
const { data, errors } = await res.json();{
"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.
mutation {
sendTestWebhook(endpointPk: 4)
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "mutation {\n sendTestWebhook(endpointPk: 4)\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation {
sendTestWebhook(endpointPk: 4)
}
`,
}),
});
const { data, errors } = await res.json();{
"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.
query {
webhookEndpoint(endpointPk: 4) {
failingSince
deliveries(first: 10, state: "pending") {
deliveryPk
eventType
attempts
lastStatus
lastError
nextAttemptAt
}
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "query {\n webhookEndpoint(endpointPk: 4) {\n failingSince\n deliveries(first: 10, state: \"pending\") {\n deliveryPk\n eventType\n attempts\n lastStatus\n lastError\n nextAttemptAt\n }\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
query {
webhookEndpoint(endpointPk: 4) {
failingSince
deliveries(first: 10, state: "pending") {
deliveryPk
eventType
attempts
lastStatus
lastError
nextAttemptAt
}
}
}
`,
}),
});
const { data, errors } = await res.json();{
"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#
- Call rotateWebhookSecret and store the new secret.
- 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.
- 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.