Webhooks
A webhook endpoint is an https URL of yours that AccountCloud sends events to, as a POST with a
JSON body. Every request is signed the Standard Webhooks way, so
any of its libraries can verify it.
Endpoints
Section titled “Endpoints”Add endpoints in the portal under Webhooks. You need the owner, admin or developer role, and two-factor authentication to add, change or delete an endpoint or to see its secret.
- Live or test. An endpoint belongs to one environment. Events of live activity only go to live endpoints, events of test mode only to test endpoints. A test event that you send from the portal goes to the endpoint you send it to.
- Event types. An endpoint receives the types you pick, or all of them (
*, which also includes types we add later). - At most 20 endpoints per account, live and test together.
What the URL must be
Section titled “What the URL must be”We check the URL when you save it, and again before every attempt:
https, on port 443 or a port from 1024 to 65535;- no user name or password in the URL, at most 2,048 characters;
- the host must be reachable from the internet: every address its name resolves to must be public.
Private, loopback, link-local and other reserved ranges are refused (IPv4 and IPv6), as are names
such as
localhostor*.local.
Redirects are not followed. To test locally, use a tunnel that gives you a public https URL.
Event types
Section titled “Event types”| Type | When |
|---|---|
credit.low_balance |
The available credit fell below your alert threshold. |
credit.auto_top_up_failed |
An automatic top-up could not be collected, so automatic top-up was switched off. |
subscription.paused |
The credit ran out for longer than the grace period, so the base subscription was paused. |
document.extracted |
Only processing: verified: a document was read and waits for a processor. |
document.completed |
A document is done; with verified, a processor approved it. |
document.failed |
Only processing: automatic: a document could not be read. |
document.rejected |
Only processing: verified: a processor rejected a document. |
ping |
A test event from the portal. Only sent when you ask for it, to one endpoint. |
The credit and subscription events are about live money, so they go to your live endpoints.
A document.* event goes to the endpoints of the document’s environment: the events of a test
document go to your test endpoints. Their data is described in the
documents guide.
The body is always type, timestamp (when the event happened) and data:
{ "type": "credit.low_balance", "timestamp": "2026-11-03T12:00:00Z", "data": { "account_id": "acc_01jbxk3v6c9m2q8r4t5w7y0z1a", "available": "1.50", "threshold": "2.00", "currency": "EUR" }}The data of the other types:
{ "account_id": "acc_01jbxk3v6c9m2q8r4t5w7y0z1a", "reason": "The payment failed." }
// subscription.paused{ "account_id": "acc_01jbxk3v6c9m2q8r4t5w7y0z1a", "status": "paused", "paused_at": "2026-11-03T00:30:00Z" }
// document.completed (the other document.* types add a field or two; see the documents guide){ "id": "doc_01jbx6y4q9t2m8rz1v3k5n7p0a", "status": "completed", "processing": "automatic", "reference": "inbox-48213", "metadata": { "administration": "8842" }, "charge": { "operation": "ocr.extract", "amount": "0.0350", "currency": "EUR" } }
// ping{ "message": "This is a test event from AccountCloud.", "endpoint_id": "whe_01jbxk3v6c9m2q8r4t5w7y0z1a" }Amounts are decimal strings in euros. Payloads are thin on purpose: ids, a status and amounts.
The request
Section titled “The request”POST /webhooks/accountcloud HTTP/1.1Content-Type: application/jsonUser-Agent: AccountCloud-Webhooks/1.0 (+https://docs.accountcloud.io/guides/webhooks/)webhook-id: evt_01jbxm0r7f2c9q4w8z1t6k3n5dwebhook-timestamp: 1793707205webhook-signature: v1,4+dyfJJD2QQ0J+4w8OAc1m/gpYF80QynkYPk4/JkDIQ=| Header | What it is |
|---|---|
webhook-id |
The id of the event (evt_…). The same on every retry and redelivery of that event: use it to process an event once. |
webhook-timestamp |
When this attempt was sent, in Unix seconds. |
webhook-signature |
v1, and the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded part of your secret after whsec_. Right after you rotate the secret it holds two signatures, separated by a space. |
Verify the signature
Section titled “Verify the signature”Find the signing secret (whsec_…) in the portal. Verify against the raw body, exactly as it
arrived: a body that you parse and serialise again no longer matches the signature. The libraries
also refuse a webhook-timestamp more than 5 minutes from now, against replays.
Node.js
Section titled “Node.js”npm install standardwebhooksimport express from "express";import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.ACCOUNTCLOUD_WEBHOOK_SECRET); // whsec_…const app = express();
// express.raw keeps the body as a Buffer, exactly as it was sent.app.post("/webhooks/accountcloud", express.raw({ type: "application/json" }), (req, res) => { let event; try { event = webhook.verify(req.body, req.headers); } catch { return res.status(400).send("invalid signature"); }
// event.type, event.timestamp, event.data res.status(204).end();});
app.listen(3000);composer require standard-webhooks/standard-webhooks<?php
require __DIR__.'/vendor/autoload.php';
use StandardWebhooks\Exception\WebhookVerificationException;use StandardWebhooks\Webhook;
$payload = file_get_contents('php://input');// The library looks the headers up by these lower-case names.$headers = [ 'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? '', 'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '', 'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '',];
try { $event = (new Webhook(getenv('ACCOUNTCLOUD_WEBHOOK_SECRET')))->verify($payload, $headers);} catch (WebhookVerificationException) { http_response_code(400); exit;}
// $event['type'], $event['timestamp'], $event['data']http_response_code(204);Libraries for other languages are in the Standard Webhooks repository.
Answering
Section titled “Answering”- Any 2xx counts as delivered. Everything else is a failure, including a 3xx.
- Answer within 10 seconds. That is the time an attempt gets in all; connecting gets 5 of them. Do slow work after you have answered.
- We keep the first kilobyte of your answer in the delivery log, and stop reading after 256 KB.
Retries
Section titled “Retries”A failed attempt is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24
hours: seven attempts in all. Each delay gets up to 10% added at random. If you answer with
Retry-After, we wait at least that long, up to 24 hours.
Events can arrive more than once, for example when your answer was lost. Use webhook-id to
process each event once.
When we switch an endpoint off
Section titled “When we switch an endpoint off”- after 3 days in which every automatic delivery to it failed;
- at once when it answers
410 Gone.
The owners, admins and developers of the account then get an e-mail. Attempts that were still planned are cancelled, and nothing is sent until you switch the endpoint on again in the portal. That also resets its failure count. One successful delivery ends a series of failures.
Test events and sending again
Section titled “Test events and sending again”In the portal you can:
- send a test event to an endpoint: a
ping, or any other type with the exampledataabove. It goes out at once, also to a disabled endpoint, so you can check a fix before you switch it on. A test event looks exactly like a real event of its type, so do not act on its example data; - see the delivery log: every attempt, with its status, the HTTP status, the duration and the start of your answer;
- send an event again: once more, now, as a new attempt. This needs an active endpoint.
Test events and sending again are never retried and do not count towards switching an endpoint off. You can do at most 10 of them per minute per endpoint.
Rotating the secret
Section titled “Rotating the secret”Rotate the secret in the portal when it may have leaked, or on a schedule. Choose how long the old secret keeps signing as well: up to 7 days, 24 hours by default, or not at all. During that time every request carries both signatures, and the libraries accept a request when either one matches. Put the new secret in your receiver before the time is up.