Webhook contract
The exact HTTP shape, signing scheme, response codes, retry policy, and idempotency rules for webhooks sent from your platform to Paperless.
Pattern A — every order-completed event.
Pattern B — the same scheme is used (in reverse) to
sign push requests to /ingest/{provider}/receipts.
Endpoint
POST https://api.paperless.example/webhooks/{provider}
Content-Type: application/json
X-Paperless-Signature: <hex-encoded HMAC-SHA256>
X-Paperless-Timestamp: <unix seconds>
{provider} is the short slug Paperless assigns to your platform on onboarding
(lowercase, alphanumeric + dashes; e.g. acme).
Request headers
| Header | Required | Notes |
|---|---|---|
Content-Type | Required | Must be application/json; charset=utf-8. |
X-Paperless-Signature | Required | Lowercase hex-encoded HMAC-SHA256. See below. |
X-Paperless-Timestamp | Required | Unix seconds at the time of signing. Rejected if skew > 5 minutes. |
User-Agent | Optional | Recommended: YourPOS-Webhook/1.0. |
Idempotency-Key | Optional | If present, should equal the body's external_event_id. |
Request body
Every event uses the same envelope:
{
"external_event_id": "evt_acme_8f3e1c9a-2c11-4c1b-9c3c-c5e1c0f1abc1",
"event_type": "order.completed",
"created_at": "2026-05-15T17:21:19Z",
"merchant_id": "MERCHANT_12345",
"location_id": "LOC_001",
"data": {
"order_id": "ord_ABC123XYZ"
}
}
| Field | Type | Required | Notes |
|---|---|---|---|
external_event_id | string | Required | Stable, globally-unique. Used by Paperless for dedup. |
event_type | string | Required | See event types below. |
created_at | ISO 8601 UTC | Required | When the event was emitted on your side. |
merchant_id | string | Required | Your merchant identifier. |
location_id | string | Optional | Required for order.* events. |
data | object | Required | Event-type-specific. See event types. |
Event types
| event_type | data shape | What it triggers on Paperless |
|---|---|---|
order.completed |
{ "order_id": "..." } |
Paperless fetches the order, resolves customer, writes receipt. Required for both patterns. |
order.updated |
{ "order_id": "..." } |
Paperless re-fetches and replaces the receipt body in place (keeps the same external_id). |
order.refunded |
{ "order_id": "...", "refund_id": "...", "amount_cents": 600 } |
Recorded as a partial-refund entry against the existing receipt. |
order.voided |
{ "order_id": "..." } |
Marks the receipt as voided; not deleted. |
location.updated |
{ "location_id": "..." } |
Paperless re-pulls /locations (Pattern A only). |
integration.disconnected |
{} |
Treats the merchant as disconnected. Stops calling your APIs. |
Signature scheme
Paperless verifies every webhook with HMAC-SHA256. Constant-time comparison; bad
signature returns 401 Unauthorized with no body.
Canonical string
canonical_string = X-Paperless-Timestamp + "." + raw_request_body signature = HMAC-SHA256(WEBHOOK_SIGNING_SECRET, canonical_string) header value = hex(signature) // lowercase, no prefix
Sign the exact bytes you put on the wire. Do not re-serialize the JSON after signing — even key reordering or whitespace changes will break verification.
Python reference
import hashlib
import hmac
import json
import time
import requests
def emit_webhook(event: dict) -> None:
body = json.dumps(event, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time()))
canonical = timestamp.encode("utf-8") + b"." + body
signature = hmac.new(
WEBHOOK_SIGNING_SECRET.encode("utf-8"),
canonical,
hashlib.sha256,
).hexdigest()
requests.post(
f"https://api.paperless.example/webhooks/{PROVIDER}",
data=body,
headers={
"Content-Type": "application/json",
"X-Paperless-Signature": signature,
"X-Paperless-Timestamp": timestamp,
},
timeout=5,
)
Verifying on your side (test fixture)
Use this snippet to verify your own signatures locally before going live. Paperless will use the equivalent logic on its side.
import hashlib
import hmac
def verify(secret: str, body: bytes, timestamp: str, signature: str,
max_skew_seconds: int = 300) -> bool:
import time
if abs(int(time.time()) - int(timestamp)) > max_skew_seconds:
return False
canonical = timestamp.encode("utf-8") + b"." + body
expected = hmac.new(
secret.encode("utf-8"), canonical, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
cURL
BODY='{"external_event_id":"evt_demo","event_type":"order.completed","created_at":"2026-05-15T17:21:19Z","merchant_id":"MERCHANT_12345","location_id":"LOC_001","data":{"order_id":"ord_demo"}}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SIGNING_SECRET" | awk '{print $2}')
curl -sS -X POST \
-H "Content-Type: application/json" \
-H "X-Paperless-Signature: $SIG" \
-H "X-Paperless-Timestamp: $TS" \
--data "$BODY" \
"https://api.paperless.example/webhooks/acme"
Response codes
| Status | Body | Meaning | Should you retry? |
|---|---|---|---|
200 OK | {"ok": true} | Accepted and queued. Order may still fail later — see DLQ. | No. |
200 OK | {"ok": true, "duplicate": true} | Duplicate external_event_id. Already deduped. | No. |
400 Bad Request | Validation error | Body did not parse or required field missing. | No — fix and re-send manually. |
401 Unauthorized | — | Bad signature or skew > 5 minutes. | No — verify your signing. |
404 Not Found | — | Unknown {provider} slug. | No — fix URL. |
413 Payload Too Large | — | Body > 256 KB. | No. |
429 Too Many Requests | — | Rate limited. Honor Retry-After. | Yes. |
5xx | — | Transient infrastructure error. | Yes. |
Timing
- Paperless will return
200within ~500 ms — usually faster. The endpoint only verifies, persists the raw event, and enqueues for async processing. - Your connect timeout should be 5 s; your read timeout 10 s. Anything longer indicates a Paperless infrastructure issue.
- The receipt becoming visible in the customer's app happens asynchronously after Paperless 200s your webhook. Typical end-to-end latency is 1–3 s.
Retry policy (yours)
You own webhook retries. The required schedule:
| Attempt | Wait before this attempt |
|---|---|
| 1 (initial) | 0 s |
| 2 | 30 s |
| 3 (final) | 60 s |
After 3 failed attempts, write the event to your local DLQ and alert your ops team. Same external_event_id on every retry; Paperless will dedupe.
Idempotency
- Paperless dedupes incoming webhooks by
external_event_id. If you re-deliver the same event, you will receive a200with"duplicate": trueand no side effect. - Receipts are also deduped by
external_id(the order ID inside the fetched order). Two distinctexternal_event_idvalues pointing at the sameexternal_idstill result in one receipt. - Retries are safe by default. Always prefer a retry over a manual reconciliation.
Common signing mistakes
- Pretty-printed JSON. Sign the exact bytes you send. If you pretty-print after signing, the signature breaks.
- Mixed-case hex. Use lowercase hex.
- Skew. If your servers' clocks drift past 5 minutes, all signatures fail. Run NTP.
- Signing the URL. The canonical string is
timestamp + "." + body— not the URL. - Encoding the secret. The secret is bytes, taken as-is from what Paperless gives you. Do not base64-decode or hex-decode it.