Webhook manual
Last updated: 11 August 2026
On every new document we send a signed POST to your address. You do not have to ask on a schedule — you find out in the same second we did.
Part of the Platinum and Accountant plans, like the API. Addresses are configured under Settings → Integrations: three per account at most, https only.
Switching it on
- Add the address under Settings → Integrations. It has to be https — a webhook carrying fiscal documents over plain http would undo every other precaution.
- We generate a secret when you add it. It is shown next to the address, and it is the key you verify the signature with.
- The first delivery goes out on the first new document found after you added it. We do not resend what came before.
- Every document produces one POST to every enabled address.
The email is the promise; the webhook is a convenience on top of it. If your server is down for a day, the email alert went out anyway.
What the request looks like
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-AlerteSPV-Timestamp | The moment of signing, in Unix seconds. |
| X-AlerteSPV-Signature | "sha256=" plus HMAC-SHA256 over "timestamp.body", with the address's secret. |
| User-Agent | AlerteSPV-Webhook/1 |
POST /your-webhook HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: AlerteSPV-Webhook/1
X-AlerteSPV-Timestamp: 1786431338
X-AlerteSPV-Signature: sha256=6f1c0a…
{"event":"document.new", …}- "event" is always "document.new" for now. Check it anyway: other events will use the same address.
- "summary" has exactly the shape it has in the API, and is null when the document has no extractable summary.
- The body does not carry the XML. Fetch it with "GET /api/v1/documents/{id}/xml", using "document.id" from the payload.
Verifying the signature
The signature is over "timestamp.body" — the timestamp, a dot, then the raw request body. Signing the body alone would let anyone who ever saw a valid request replay it forever; with the timestamp inside, you can reject an old request without us keeping any state.
Verify against the raw body, the exact bytes received. If you decode it into an object and re-serialise it, key order and escaping change, and the signature will never match again.
<?php
$secret = getenv('ALERTESPV_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_ALERTESPV_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_ALERTESPV_SIGNATURE'] ?? '';
// Old requests are refused: a five-minute window closes off replays.
if (! ctype_digit((string) $timestamp) || abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit('timestamp');
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (! hash_equals($expected, $signature)) {
http_response_code(401);
exit('signature');
}
$payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
// Idempotent on the id: the same delivery can arrive twice.
// upsert($payload['document']['id'], $payload);
http_response_code(200);What you have to answer
- Any 2xx means delivered. 200 or 204, it makes no difference.
- Anything else — 3xx, 4xx, 5xx — means failed, and starts the retries.
- You have 10 seconds. Past that we close the connection and treat the delivery as failed.
- Put the work on a queue and answer immediately. A webhook that parses XML synchronously will blow the limit on exactly the busy day.
Retries and duplicates
Every delivery gets 4 attempts: immediately, then after roughly a minute, five minutes and twenty-five minutes.
| Attempt | When |
|---|---|
| 1 | As soon as the document has been stored. |
| 2 | After 60 seconds. |
| 3 | After another 5 minutes. |
| 4 | After another 25 minutes. |
The same delivery can arrive twice — for instance if your server received it and answered late. Be idempotent on "document.id": store it as unique and ignore what you have already seen.
When we switch an address off
After 20 consecutive failures the address switches itself off. This is not housekeeping for its own sake: an address that has been refusing us for a fortnight is one we would hammer on every document, for every account that configured it once and moved on.
- The last error and the number of consecutive failures are shown next to the address, under Settings → Integrations.
- A successful delivery resets the counter to zero.
- A disabled address is brought back by deleting it and adding it again. The new secret has to be updated on your side too.
- Deliveries from the period the address was off are not recovered.
Testing before production
The address has to be https and reachable from the internet, so a "localhost" will not do. A public tunnel to your development machine is the simplest route; the alternative is to reproduce a signed request locally and test your verification against it.
# Reproduce a signed delivery against your own endpoint.
SECRET="the-secret-shown-in-the-panel"
BODY='{"event":"document.new","document":{"id":8814}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -s -X POST https://example.com/webhook/alertespv \
-H "Content-Type: application/json" \
-H "X-AlerteSPV-Timestamp: $TS" \
-H "X-AlerteSPV-Signature: sha256=$SIG" \
--data-raw "$BODY"- Check first that you reject a wrong signature. An endpoint that accepts anything looks identical from the outside.
- Then check that you reject a timestamp an hour old.
- Only then check the happy path.