Webhook contract

The exact HTTP shape, signing scheme, response codes, retry policy, and idempotency rules for webhooks sent from your platform to Paperless.

Applies to

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

HeaderRequiredNotes
Content-TypeRequiredMust be application/json; charset=utf-8.
X-Paperless-SignatureRequiredLowercase hex-encoded HMAC-SHA256. See below.
X-Paperless-TimestampRequiredUnix seconds at the time of signing. Rejected if skew > 5 minutes.
User-AgentOptionalRecommended: YourPOS-Webhook/1.0.
Idempotency-KeyOptionalIf 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"
  }
}
FieldTypeRequiredNotes
external_event_idstringRequiredStable, globally-unique. Used by Paperless for dedup.
event_typestringRequiredSee event types below.
created_atISO 8601 UTCRequiredWhen the event was emitted on your side.
merchant_idstringRequiredYour merchant identifier.
location_idstringOptionalRequired for order.* events.
dataobjectRequiredEvent-type-specific. See event types.

Event types

event_typedata shapeWhat 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 raw body

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

StatusBodyMeaningShould 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 RequestValidation errorBody did not parse or required field missing.No — fix and re-send manually.
401 UnauthorizedBad signature or skew > 5 minutes.No — verify your signing.
404 Not FoundUnknown {provider} slug.No — fix URL.
413 Payload Too LargeBody > 256 KB.No.
429 Too Many RequestsRate limited. Honor Retry-After.Yes.
5xxTransient infrastructure error.Yes.

Timing

Retry policy (yours)

You own webhook retries. The required schedule:

AttemptWait before this attempt
1 (initial)0 s
230 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

Common signing mistakes