Answer: Subscribe a URL with POST /api/webhooks and ASHDOCS will POST you signed JSON on job.completed, job.failed, pipeline.completed, pipeline.failed and review.approved. Every delivery is signed with HMAC-SHA256 in the X-ASHDOCS-Signature header, retried up to 3 times, and logged in your dashboard.
Subscribing
Available on paid plans (Free returns 403 webhooks_not_available). The creation response includes your signing secret (prefix whsec_) — store it; you'll verify every delivery with it.
curl -X POST https://www.ashdocs.com/api/webhooks \
-H "X-API-Key: ash_live_xxxxxxxx" -H "Content-Type: application/json" \
-d '{ "url": "https://your.app/hooks/ashdocs",
"events": ["job.completed", "job.failed"] }'
# -> { "webhook_id": "wh_...", "secret": "whsec_...", ... }Delivery format
Every event is a POST with a JSON body of {event, data, delivered_at} and two headers: X-ASHDOCS-Event (the event type) and X-ASHDOCS-Signature (sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your whsec_ secret).
POST /hooks/ashdocs HTTP/1.1
Content-Type: application/json
X-ASHDOCS-Event: job.completed
X-ASHDOCS-Signature: sha256=6c2f9a…
{ "event": "job.completed",
"data": { "job_id": "job_01H…", "request_id": "req_…", "tool": "html-to-pdf",
"outputs": [{ "filename": "output.pdf", "url": "https://…signed" }],
"credits_consumed": 1 },
"delivered_at": "2026-08-19T09:00:00+00:00" }Verifying the signature (Python)
Compute the HMAC over the RAW request body bytes — not a re-serialized version — and compare with a constant-time function.
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)Verifying the signature (Node.js)
Same idea with the crypto module. Make sure your framework gives you the raw body (in Express, use express.raw() for the webhook route).
const crypto = require("crypto");
function verify(rawBody, header, secret) {
const expected = "sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}Retries, timeouts and the delivery log
Each delivery gets 3 attempts with waits of 1s, 2s and 4s between them and a 10-second timeout per attempt. Any 2xx from your endpoint counts as delivered; anything else (or a timeout) triggers the next attempt. Every attempt's status and response snippet is recorded — see the Deliveries page in your dashboard, where failed deliveries can be replayed.
Idempotency on your side
After the 3rd failed attempt the event is marked failed (you can replay it manually), so duplicates are possible when your endpoint is flaky — a 2xx that ASHDOCS never received, then a replay. Dedupe on (event, data.job_id): processing the same pair twice should be a no-op in your handler.
Testing locally
Point the webhook URL at any public tunnel to your machine (Cloudflare Tunnel, ngrok, or similar), fire a sandbox job with your ash_test_ key, and you'll receive a real signed job.completed within seconds — sandbox jobs dispatch webhooks exactly like live ones.
Event payloads
job.completed carries {job_id, request_id, tool, outputs[{filename, url}], credits_consumed}. job.failed carries {job_id, request_id, tool, error}. pipeline.completed / pipeline.failed carry the pipeline run summary, and review.approved carries the approved extraction's data — field names match what the dashboard shows for the same objects.
Frequently asked questions
How long are the signed output URLs in webhook payloads valid?
One hour, same as API responses. Download promptly or re-fetch the job via GET /api/v1/jobs/{job_id} for a fresh URL.
Can I subscribe one URL to several events?
Yes — pass any subset in the events array, and branch on the X-ASHDOCS-Event header or body.event in your handler.
What happens after all 3 attempts fail?
The event is stored as failed with the response we saw. You can inspect and replay it from the Deliveries page — nothing is silently lost.