Skip to content
API console (try a request with your own key)Get early access

Receiving webhooks

When a file you sent us has been read, we tell your server straight away by sending it an HTTP request, called a webhook, so your software doesn’t have to keep asking. Every endpoint your workspace has added receives a POST with a JSON body when a file finishes, saying one of two things:

  • extraction.completed: every sheet of the file has finished. Each was read, or failed and was refunded, and some values may be waiting for review.
  • extraction.failed: the file could not be read at all, and all its credits were returned.

The body names the event (id, type, createdAt) and carries the job under data.job exactly as GET /v1/extractions/{jobId} returns it, with links to download its results under data.exports.

Why every delivery is signed. Anyone who learns your endpoint’s address could send it a request that looks like ours. So when you add an endpoint, we give you a signing secret: it starts with whsec_ and is shown only once. We use it to sign every delivery, and your server uses its copy to check the signature before it trusts the body. If the signature matches and the delivery is recent, it came from us and nothing in it was changed on the way.

Deliveries follow Standard Webhooks, an open specification for signing webhooks, so you don’t have to write any cryptography yourself: an official library for your language does the whole check.

The Standard Webhooks project publishes a library for each of these languages:

Each library needs three things: your signing secret, the request body exactly as it arrived, and the request’s headers. It checks the signature, and that the delivery was sent less than 5 minutes ago, and raises an error when either check fails. When it does, answer 400 and do nothing else with the request.

In each example below, the first line is the command that installs the library, and the secret is read from an environment variable named SPECCAPTURE_WEBHOOK_SECRET.

// npm install standardwebhooks
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.SPECCAPTURE_WEBHOOK_SECRET); // whsec_...
// rawBody: the request body exactly as it arrived; headers: the request's headers.
const event = webhook.verify(rawBody, headers); // throws if it did not come from us

The specification describes the scheme in full, and its repository lists the libraries, including ones written by the community for other languages.

If you can’t add a library, the check is a few lines of code using your language’s own HMAC-SHA256: a keyed fingerprint of a message, which only someone holding the same key can produce. Every delivery carries three headers:

  • webhook-id: the event’s id, the same on every retry of it (it is also the body’s id).
  • webhook-timestamp: when this attempt was sent, in Unix seconds (seconds since 1 January 1970, UTC).
  • webhook-signature: v1, followed by the signature in base64, a way of writing bytes as text. The header may hold several signatures separated by spaces; one match is enough.

To check a delivery:

  1. Take your signing secret, remove the whsec_ at its start, and base64-decode the rest. The bytes you get are the key.
  2. Join the webhook-id header, a full stop, the webhook-timestamp header, a full stop, and the request body exactly as it arrived. Compute HMAC-SHA256 of that with the key.
  3. Write the result in base64 and put v1, in front of it. The delivery came from us when that equals one of the signatures in webhook-signature, compared in constant time (see below), and its timestamp is within 5 minutes of your clock.
  4. Answer with any 2xx status (200 to 299) within 10 seconds, and do the work afterwards.

The same check in Node.js, Python and C#:

const crypto = require("node:crypto");
// secret: your endpoint's signing secret (whsec_...); body: the raw request body, as received.
function isFromSpecCapture(secret, headers, body) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = "v1," + crypto.createHmac("sha256", key)
.update(`${id}.${timestamp}.${body}`)
.digest("base64");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
return fresh && headers["webhook-signature"].split(" ").some(signature =>
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)));
}
  • Checking the signature against parsed JSON. The signature covers the exact bytes we sent. If your framework turns the body into an object and you turn that back into JSON, the spacing, the order of the keys or the way characters such as ± and ⌀ are written can change, and the check fails. Read the raw body before anything parses it: express.raw({ type: "application/json" }) in Express, request.get_data() in Flask, request.body in Django, Request.Body read with a StreamReader in ASP.NET Core, request.raw_post in Rails, file_get_contents('php://input') in PHP.
  • Using the secret as it is. The secret is whsec_ followed by base64. The key is the bytes you get by base64-decoding the part after whsec_, not the text of the secret. The libraries do this for you.
  • Comparing signatures with ==. An ordinary comparison stops at the first character that differs, so how long it takes tells an attacker how much of a guessed signature was right. Use your language’s constant-time comparison: crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, CryptographicOperations.FixedTimeEquals in .NET, hmac.Equal in Go, MessageDigest.isEqual in Java, hash_equals in PHP.
  • Skipping the timestamp check. Refuse a delivery whose webhook-timestamp is more than 5 minutes from your clock. Without that check, someone who captured one delivery could send it to you again days later (a “replay”), and its signature would still be valid. Keep your server’s clock set automatically.
  • Doing the work before answering. We wait 10 seconds for your answer. Answer with a 2xx as soon as the signature checks out, and do the work afterwards, for example from a queue. A slow answer counts as a failure, and we send the delivery again.
  • Handling the same event twice. We deliver at least once: if your answer is lost or late, we send the event again, and your code may already have handled it. Every attempt of an event carries the same webhook-id, so keep the ids you have handled and ignore one you have seen before.
  • A delivery succeeds when your server answers with any 2xx status within 10 seconds. We don’t follow redirects: a 3xx answer counts as a failure.
  • Anything else, such as an error status, no answer in time or a connection that fails, is tried again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours: 7 attempts in all, over about 18 hours. Each attempt carries the same webhook-id and body, with a new timestamp and signature. Retries go out from a timer that runs once a minute, so each wait is a minimum.
  • After the 7th failed attempt we give up on that delivery. If your server answers 410 Gone, we give up on that delivery at once and stop the endpoint: 410 Gone means it accepts no more events, so we send it nothing more, not even a delivery waiting for its next try. The Webhooks screen shows it as stopped, and the owner or an admin can turn it back on there, or with POST /v1/webhooks/{webhookId}/resume. Drawings finished while it was stopped are not sent. To turn deliveries away for a while instead, answer with any other error, such as 503, and we keep trying.
  • The Webhooks screen in the dashboard shows how each endpoint is doing and its recent deliveries: when each was sent, the status your server answered, the attempts so far, the next try, and in a few words why a delivery has not arrived. GET /v1/webhooks/{webhookId}/deliveries returns the same log.
  • A delivery we gave up on is not sent again, but the job is unaffected: fetch it and its results with GET /v1/extractions/{jobId}.

We deliver only to public https addresses, so a server running on your own computer (localhost) can’t receive deliveries directly. While you develop, run a tunnel: a small program that gives your local server a temporary public https address and passes the requests it receives on to your machine. Two widely used ones, each with its own guide:

  • ngrok: ngrok http 8080, once you have an ngrok account.
  • Cloudflare Tunnel: cloudflared tunnel --url http://localhost:8080, with no account needed for a quick tunnel.

Replace 8080 with the port your server listens on. Add the https address the tunnel prints, followed by your server’s path (for example /webhooks), as an endpoint on the Webhooks screen, and submit a drawing: the delivery arrives on your machine, and the Webhooks screen shows how it went. Many tunnels give a new address each time they start; when yours changes, delete the old endpoint and add the new address, which comes with a new secret.