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 easy way: an official library
Section titled “The easy way: an official library”The Standard Webhooks project publishes a library for each of these languages:
- Node.js:
standardwebhookson npm - Python:
standardwebhookson PyPI - C# and .NET:
StandardWebhooks.StandardWebhookson NuGet - Go:
github.com/standard-webhooks/standard-webhooks/libraries/go - Java:
com.standardwebhooks:standardwebhookson Maven Central - PHP:
standard-webhooks/standard-webhookson Packagist - Ruby:
standardwebhookson RubyGems - Rust:
standardwebhookson crates.io
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 standardwebhooksimport { 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# pip install standardwebhooksimport osfrom standardwebhooks import Webhook
webhook = Webhook(os.environ["SPECCAPTURE_WEBHOOK_SECRET"]) # whsec_...# raw_body: the request body exactly as it arrived; headers: the request's headers.event = webhook.verify(raw_body, dict(headers)) # raises if it did not come from us// dotnet add package StandardWebhooks.StandardWebhooksusing System.Net;using StandardWebhooks;
var webhook = new Webhook(Environment.GetEnvironmentVariable("SPECCAPTURE_WEBHOOK_SECRET")); // whsec_...// rawBody: the request body exactly as it arrived, as a string. The library reads these three headers.var headers = new WebHeaderCollection { { "webhook-id", Request.Headers["webhook-id"] }, { "webhook-timestamp", Request.Headers["webhook-timestamp"] }, { "webhook-signature", Request.Headers["webhook-signature"] },};webhook.Verify(rawBody, headers); // throws WebhookVerificationException if it did not come from us// go get github.com/standard-webhooks/standard-webhooks/libraries/goimport standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
webhook, err := standardwebhooks.NewWebhook(os.Getenv("SPECCAPTURE_WEBHOOK_SECRET")) // whsec_...// rawBody: the request body exactly as it arrived (io.ReadAll(r.Body)); r.Header: the request's headers.err = webhook.Verify(rawBody, r.Header) // an error if it did not come from us// Maven or Gradle: com.standardwebhooks:standardwebhooks:1.2.0import com.standardwebhooks.Webhook;
Webhook webhook = new Webhook(System.getenv("SPECCAPTURE_WEBHOOK_SECRET")); // whsec_...// rawBody: the request body exactly as it arrived; headers: the request's headers, a Map<String, List<String>>.webhook.verify(rawBody, headers); // throws WebhookVerificationException if it did not come from us// composer require standard-webhooks/standard-webhooksrequire __DIR__ . '/vendor/autoload.php';
$webhook = new \StandardWebhooks\Webhook(getenv('SPECCAPTURE_WEBHOOK_SECRET')); // whsec_...$event = $webhook->verify(file_get_contents('php://input'), [ 'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'], 'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'], 'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'],]); // throws if it did not come from us# gem install standardwebhooksrequire "standardwebhooks"
webhook = StandardWebhooks::Webhook.new(ENV.fetch("SPECCAPTURE_WEBHOOK_SECRET")) # whsec_...event = webhook.verify(request.raw_post, { "webhook-id" => request.headers["webhook-id"], "webhook-timestamp" => request.headers["webhook-timestamp"], "webhook-signature" => request.headers["webhook-signature"],}) # raises StandardWebhooks::WebhookVerificationError if it did not come from us// cargo add standardwebhooksuse standardwebhooks::Webhook;
let webhook = Webhook::new(&std::env::var("SPECCAPTURE_WEBHOOK_SECRET")?)?; // whsec_...// raw_body: the request body exactly as it arrived (bytes); headers: the request's http::HeaderMap.webhook.verify(&raw_body, &headers)?; // an error if it did not come from usThe specification describes the scheme in full, and its repository lists the libraries, including ones written by the community for other languages.
The manual way: checking it yourself
Section titled “The manual way: checking it yourself”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’sid).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:
- Take your signing secret, remove the
whsec_at its start, and base64-decode the rest. The bytes you get are the key. - Join the
webhook-idheader, a full stop, thewebhook-timestampheader, a full stop, and the request body exactly as it arrived. Compute HMAC-SHA256 of that with the key. - Write the result in base64 and put
v1,in front of it. The delivery came from us when that equals one of the signatures inwebhook-signature, compared in constant time (see below), and its timestamp is within 5 minutes of your clock. - 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)));}import base64, hashlib, hmac, time
# secret: your endpoint's signing secret (whsec_...); body: the raw request body, as bytes.def is_from_speccapture(secret: str, headers, body: bytes) -> bool: webhook_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{webhook_id}.{timestamp}.".encode() + body expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() fresh = abs(time.time() - int(timestamp)) <= 300 return fresh and any(hmac.compare_digest(signature, expected) for signature in headers["webhook-signature"].split())using System.Security.Cryptography;using System.Text;
// secret: your endpoint's signing secret (whsec_...); body: the raw request body, as received.static bool IsFromSpecCapture(string secret, string webhookId, string timestamp, string signatures, string body){ byte[] key = Convert.FromBase64String(secret["whsec_".Length..]); byte[] hash = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{webhookId}.{timestamp}.{body}")); byte[] expected = Encoding.ASCII.GetBytes("v1," + Convert.ToBase64String(hash)); bool fresh = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)) <= 300; return fresh && signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries) .Any(signature => CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(signature), expected));}Common mistakes
Section titled “Common mistakes”- 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.bodyin Django,Request.Bodyread with aStreamReaderin ASP.NET Core,request.raw_postin 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 afterwhsec_, 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.timingSafeEqualin Node.js,hmac.compare_digestin Python,CryptographicOperations.FixedTimeEqualsin .NET,hmac.Equalin Go,MessageDigest.isEqualin Java,hash_equalsin PHP. - Skipping the timestamp check. Refuse a delivery whose
webhook-timestampis 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.
Retries, and where to see failures
Section titled “Retries, and where to see failures”- 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-idand 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 Gonemeans 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 withPOST /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 as503, 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}/deliveriesreturns 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}.
Testing on your own machine
Section titled “Testing on your own machine”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.