skmtcdocs

API change webhooks

Get a signed CloudEvent at your HTTPS endpoint when an API on SKMTC Hub changes, and verify that Hub sent it.

An API on SKMTC Hub can get a new latest revision that differs from the revision before it. Then Hub sends a signed CloudEvent to each webhook on that API. This page tells you how to add a webhook, receive the request, and check its signature.

An OpenAPI 3.1 document describes the request. It has one entry under webhooks and no paths:

https://docs.skmtc.dev/openapi/api-change-webhooks-v1.json

Download the OpenAPI document. Load it by URL in Swagger Editor, Scalar or a code generator. It gives the request headers, the event schemas, and what Hub does with each response.

This page is about the webhooks Hub sends to you. It is not about the webhooks section of an OpenAPI document that you publish.

Quick start

  1. Sign in to Hub and open the page of the API.
  2. Select Add webhook, enter the Endpoint URL of your receiver, and select Add webhook.
  3. Copy the secret. Hub shows it one time only. Keep it where your receiver can read it.
  4. Select Send test event. Hub sends a test event at once and shows the result in the dialog. The test is optional.

You can add a webhook to any API you can view. You can add more than one endpoint to an API, but each endpoint URL only one time.

To manage your webhooks later, open the Subscriptions tab of your account (https://skmtc.dev/{your-handle}/subscriptions). It lists your API webhooks across all APIs.

Endpoint requirements

  • The URL uses https.
  • The host is public. Hub refuses loopback, private and reserved addresses.
  • The URL contains no user name or password.

Hub checks the URL when you add the webhook. Before each request, Hub checks the URL, each redirect, and each address that DNS gives for the host. If a check fails, Hub does not send the request, and the delivery shows Destination blocked.

The request

Hub sends one HTTP POST for each event. The body is one CloudEvent 1.0 in structured mode, as JSON. Hub sends the body on one line; this page adds line breaks.

POST /webhooks/skmtc HTTP/1.1
content-type: application/cloudevents+json; charset=utf-8
user-agent: SKMTC-Hub-Webhooks/1
skmtc-signature: t=1791884413,v1=54cefefcde2f4a2c76ac705d7cfc429125ca0b313e8850f03a97644c14f5762a

{
  "specversion": "1.0",
  "id": "whe_5d0b6f1e2a9c4d3b8e7f6a5b4c3d2e1f",
  "source": "https://skmtc.dev",
  "subject": "acme/petstore",
  "time": "2026-10-13T09:40:12.000Z",
  "datacontenttype": "application/json",
  "type": "dev.skmtc.api.revision.v1",
  "data": {
    "api": {
      "id": "api_8f2c1d4e5b6a47c9a0e3f1b2c3d4e5f6",
      "owner": "acme",
      "slug": "petstore",
      "name": "Petstore",
      "url": "https://skmtc.dev/acme/apis/petstore"
    },
    "revision": {
      "id": "ver_0c9e8d7f6a5b4c3d2e1f0a9b8c7d6e5f",
      "contentHash": "3b4f1c2d9e8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c",
      "version": "v2",
      "publishedAt": "2026-10-13T09:12:00.000Z",
      "retrievedAt": "2026-10-13T09:40:05.000Z",
      "url": "https://skmtc.dev/acme/apis/petstore/revisions/3b4f1c2d9e8a"
    },
    "previousRevision": {
      "id": "ver_7a6b5c4d3e2f41a0b9c8d7e6f5a4b3c2",
      "contentHash": "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d",
      "version": "v2",
      "publishedAt": null,
      "retrievedAt": "2026-10-06T14:02:51.000Z",
      "url": "https://skmtc.dev/acme/apis/petstore/revisions/9e8d7c6b5a4f"
    },
    "summary": {
      "diff": true,
      "details": {
        "endpoints": { "added": 1, "deleted": 0, "modified": 2 },
        "schemas": { "added": 0, "deleted": 1, "modified": 1 }
      }
    },
    "counts": { "breaking": 1, "warning": 0, "info": 3 },
    "changesUrl": "https://skmtc.dev/acme/apis/petstore/revisions/3b4f1c2d9e8a/changelog"
  }
}

Headers

HeaderValue
content-typeapplication/cloudevents+json; charset=utf-8
user-agentSKMTC-Hub-Webhooks/1
skmtc-signaturet=<unix seconds>,v1=<hex HMAC-SHA256>. See Verify the signature.

Event types

typeSent when
dev.skmtc.api.revision.v1A push or a refetch makes a new revision the latest revision of the API, and the diff with the revision before it finds a change.
dev.skmtc.api.test.v1You select Send test event.

Hub does not send an event when the diff finds no change, for example after a commit that changes only spaces and line breaks. A revision that Hub imports into the history of an API, behind its latest revision, does not send an event.

CloudEvent attributes

  • id is the same on every try and every redelivery of an event. Use it to find duplicates.
  • source is the Hub site that sent the event.
  • subject is the API, as {owner}/{slug}.
  • time gives the time when Hub recorded the event. It is not the publisher's time.

Revision event data

  • api: the id, owner, slug, name and url of the API.
  • revision: the revision that became the latest revision.
  • previousRevision: the latest revision before it. It can belong to a different version of the API.
  • summary.details: the number of added, deleted and modified items in each section of the document.
  • counts: the number of changes at each severity: breaking, warning and info.
  • changesUrl: the Hub page that lists the changes of revision.

Each revision has these fields:

  • id: the Hub id of the revision.
  • contentHash: the SHA-256 of the document. It is null for a revision with no stored document.
  • version: the publisher's version, or null. It does not give an order.
  • publishedAt: the publisher's time, or null when the publisher gave none.
  • retrievedAt: when Hub retrieved the revision.
  • url: the revision's page on Hub. For a revision with no stored document, it is the API's revisions page.

The event contains a summary and links, not the document. For a private API, the links open only for a signed-in user who can read the API.

Test event

A test event has the same headers and attributes. Its data names the API and contains no revision:

{
  "specversion": "1.0",
  "id": "whe_e4d3c2b1a0f94e8d7c6b5a4f3e2d1c0b",
  "source": "https://skmtc.dev",
  "subject": "acme/petstore",
  "time": "2026-10-13T09:45:00.000Z",
  "datacontenttype": "application/json",
  "type": "dev.skmtc.api.test.v1",
  "data": {
    "test": true,
    "api": {
      "id": "api_8f2c1d4e5b6a47c9a0e3f1b2c3d4e5f6",
      "owner": "acme",
      "slug": "petstore",
      "name": "Petstore",
      "url": "https://skmtc.dev/acme/apis/petstore"
    },
    "message": "A test event from SKMTC Hub. It describes no revision."
  }
}

Verify the signature

The skmtc-signature header has the form t=<unix seconds>,v1=<hex>. The v1 value is the HMAC-SHA256 of <t>.<body>:

  • The key is the whole secret of the webhook as UTF-8, with its whsec_ prefix.
  • <t> is the t value from the header.
  • <body> is the raw request body.

Your receiver must:

  1. Calculate the HMAC from the raw body. Do not parse the JSON first. A body that you parse and serialize again can change by one byte, and then the signature does not match.
  2. Compare the result with v1 in constant time.
  3. Refuse a request whose t is more than five minutes from your clock.
  4. Use the CloudEvent id to ignore an event that you already processed.

This receiver does those steps. It uses only Web Crypto and the Fetch API Request and Response, so it runs on Deno, Bun and Cloudflare Workers. On Node.js, compile it to JavaScript first, or use Node.js 23.6 or later, which runs TypeScript.

// receiver.ts
const encoder = new TextEncoder()
const TOLERANCE_SECONDS = 5 * 60

/** Whether `header` is a valid skmtc-signature of `body` for `secret`. */
export const verifySignature = async (
  secret: string,
  header: string | null,
  body: string,
  now: number = Math.floor(Date.now() / 1000)
): Promise<boolean> => {
  const parts = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '')
  if (!parts) return false
  const [, timestamp = '', hex = ''] = parts
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false

  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify']
  )
  const signature = new Uint8Array(32).map((_, index) =>
    parseInt(hex.slice(index * 2, index * 2 + 2), 16)
  )
  // `verify` compares in constant time.
  return crypto.subtle.verify('HMAC', key, signature, encoder.encode(`${timestamp}.${body}`))
}

// Event ids you already processed. Use a durable store in production.
const processed = new Set<string>()

export const handleWebhook = async (request: Request, secret: string): Promise<Response> => {
  const body = await request.text()
  const signature = request.headers.get('skmtc-signature')
  if (!(await verifySignature(secret, signature, body))) {
    return new Response('Invalid signature', { status: 401 })
  }

  const event = JSON.parse(body)
  if (processed.has(event.id)) return new Response(null, { status: 204 })

  if (event.type === 'dev.skmtc.api.revision.v1') {
    const { breaking, warning, info } = event.data.counts
    console.log(`${event.subject}: ${breaking} breaking, ${warning} warning, ${info} info`)
    console.log(`Changes: ${event.data.changesUrl}`)
  }
  // After the work: if it throws, Hub gets a 5xx and the retry runs it again.
  processed.add(event.id)
  return new Response(null, { status: 204 })
}

To run it on Deno:

// main.ts
import { handleWebhook } from './receiver.ts'

const secret = Deno.env.get('SKMTC_WEBHOOK_SECRET')
if (!secret) throw new Error('Set SKMTC_WEBHOOK_SECRET to the signing secret')

Deno.serve((request) => handleWebhook(request, secret))
SKMTC_WEBHOOK_SECRET=whsec_… deno run --allow-net --allow-env main.ts

Hub needs a public HTTPS URL. To receive events on your computer, put a tunnel in front of the receiver.

Check your code with an example

With this secret, timestamp and body, your code must calculate the signature below. The body is the test event above, as Hub sends it: one line, with no line break at the end.

secret:    whsec_l8m3Yl0iXq2t5Q7bGHzv9dN4kRcEwUjA1sPfoT6xK0M
timestamp: 1791884700
body:      {"specversion":"1.0","id":"whe_e4d3c2b1a0f94e8d7c6b5a4f3e2d1c0b","source":"https://skmtc.dev","subject":"acme/petstore","time":"2026-10-13T09:45:00.000Z","datacontenttype":"application/json","type":"dev.skmtc.api.test.v1","data":{"test":true,"api":{"id":"api_8f2c1d4e5b6a47c9a0e3f1b2c3d4e5f6","owner":"acme","slug":"petstore","name":"Petstore","url":"https://skmtc.dev/acme/apis/petstore"},"message":"A test event from SKMTC Hub. It describes no revision."}}
signature: t=1791884700,v1=e4db6c8606b90774937b3e156acce017b8cbf64067d9e3f6ac592f9ff3bb890a

The request in The request has a signature from the same secret.

Respond

Return a 2xx status within 10 seconds. Hub does not read the response body. If the work is slow, put the event in a queue and respond. Hub counts a slow response as a timeout, and sends the event again.

Your responseWhat Hub does
Any 2xxThe delivery succeeded.
307 or 308Hub sends the same request to the location, for not more than three redirects.
408, 429, any 5xx, a timeout or a network errorHub tries again later.
Any other 3xx or 4xxThe delivery fails. Hub does not try again.

Hub does not send the request again for a 301, 302 or 303 redirect, because a client changes the POST to a GET for these. Give the final URL as the endpoint URL.

Retries, duplicates and order

Hub tries a delivery again for not more than 24 hours after its first try. The first waits are about one minute, and each wait is about two times the wait before it, to not more than six hours. When the 24 hours end, the delivery fails with Retries ran out. A failed delivery does not stop later deliveries to the webhook.

Each try has a new t and a new signature, but the same event id.

Delivery is at least once: your receiver can get the same event more than one time, for example when Hub did not get your response. Use the event id to find duplicates.

Hub does not guarantee the order of deliveries. Use previousRevision to find out if you missed an event.

Pause, resume and delete

  • Pause. Hub keeps the deliveries that it did not send yet, and sends them after you resume. Hub sends no event for a revision that becomes the latest revision during the pause.
  • Resume. Hub sends the kept deliveries within about one minute.
  • Delete. Hub cancels the deliveries that it did not send yet, and removes the secret of the webhook. Hub removes the records of the webhook 30 days after the delete.

You cannot send a test event to a paused webhook.

Access to the API

Hub checks that you can still read the API before each request. If you can no longer read it, Hub cancels the deliveries that it did not send yet, shows Access lost, and pauses the webhook. Hub does not send the canceled events later, even if you get access again. To resume the webhook, you must be able to read the API.

Rotate the secret

Select Rotate secret in the actions of the webhook. Hub shows the new secret one time. Hub signs the next try with the new secret. This also applies to tries of events from before the rotation.

Until your receiver has the new secret, it refuses Hub's requests, and the deliveries fail. After you update the receiver, select Redeliver on each failed delivery. Do not pause the webhook for the rotation: Hub sends no event for a revision that becomes the latest revision during a pause.

Delivery history and troubleshooting

In the Subscriptions tab of your account, select a webhook to see its history. The history shows each try of the last 30 days: the event, the time, the response status, and the error.

ErrorMeaning
Rejected by the destinationYour endpoint returned a 3xx or 4xx that Hub does not retry.
Retries ran outThe endpoint did not return a 2xx within 24 hours.
Destination blockedThe URL, a redirect, or a DNS address is not a public HTTPS destination.
Access lostYou can no longer read the API.
Webhook deletedThe webhook was deleted before the delivery was sent.
Timed outYour endpoint did not answer within 10 seconds. Hub tries again.
Network errorHub could not connect to your endpoint, or the connection failed. Hub tries again.
DNS errorHub could not find an address for the host. Hub tries again.
Event unavailableA revision that the event names, or its change summary, is missing. Hub does not try again.
Secret unavailableHub could not read the signing secret. Hub tries again.

To send a failed delivery again, select Redeliver on its newest try. The webhook must be active. A redelivery has the same event id and a new 24-hour retry window.

On this page