Skip to content

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.

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.

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 localhost or *.local.

Redirects are not followed. To test locally, use a tunnel that gives you a public https URL.

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:

credit.auto_top_up_failed
{ "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.

POST /webhooks/accountcloud HTTP/1.1
Content-Type: application/json
User-Agent: AccountCloud-Webhooks/1.0 (+https://docs.accountcloud.io/guides/webhooks/)
webhook-id: evt_01jbxm0r7f2c9q4w8z1t6k3n5d
webhook-timestamp: 1793707205
webhook-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.

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.

Terminal window
npm install standardwebhooks
server.js
import 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);
Terminal window
composer require standard-webhooks/standard-webhooks
webhook.php
<?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.

  • 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.

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.

  • 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.

In the portal you can:

  • send a test event to an endpoint: a ping, or any other type with the example data above. 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.

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.