Pattern B — Push mode
The one-way model. You POST a complete, signed receipt to Paperless after every completed sale. No OAuth, no order fetch, no token refresh.
- Paperless ops provisions your merchant out of band and shares a signing key + API key.
- For every completed sale, you POST a full
Receiptobject to/ingest/{provider}/receipts. - You sign the request with HMAC-SHA256 and include the static API key in
Authorization. - Paperless dedupes by
external_id, resolves the customer, and stores the receipt.
1. Onboarding
Without OAuth, you onboard a new merchant by working with Paperless ops directly:
- You provide: your merchant ID, business name, country, locations, tax number.
- Paperless creates the merchant record and returns:
API_KEY,SIGNING_SECRET,providerslug, base URL. - You store the secrets in a vault on your side. Treat them like production database credentials.
Paperless will rotate the signing secret every 12 months by default. You can request rotation at any time. A dual-key window of 7 days lets you flip cleanly without dropping webhooks. See Security for the procedure.
2. The push endpoint
POST https://api.paperless.example/ingest/{provider}/receipts
Content-Type: application/json
Authorization: Bearer <API_KEY>
X-Paperless-Signature: <hex-encoded HMAC-SHA256 of the raw body>
X-Paperless-Timestamp: 1747329679
Idempotency-Key: ord_ABC123XYZ
| Header | Required | Notes |
|---|---|---|
Authorization | Required | Bearer with the static API key. |
X-Paperless-Signature | Required | HMAC-SHA256 of the raw body, hex-encoded. |
X-Paperless-Timestamp | Required | Unix seconds. Rejected if skew > 5 minutes. |
Idempotency-Key | Required | Should equal the receipt's external_id. |
Content-Type | Required | Must be application/json. |
2.1 Request body
The body is a complete Receipt object. See
payload schema for every field. Full example:
{
"external_id": "ord_ABC123XYZ",
"external_event_id": "evt_acme_8f3e1c9a",
"merchant_id": "MERCHANT_12345",
"location": {
"location_id": "LOC_001",
"name": "Acme Coffee — King St",
"address": "123 King St W",
"city": "Toronto",
"region": "ON",
"country": "CA",
"postal_code": "M5H 1A1",
"timezone": "America/Toronto"
},
"merchant": {
"business_name": "Acme Coffee",
"tax_number": "123456789RT0001"
},
"purchased_at": "2026-05-15T17:21:19Z",
"currency": "CAD",
"subtotal_cents": 1200,
"tax_cents": 157,
"total_cents": 1357,
"line_items": [
{
"name": "Latte",
"quantity": 1,
"unit_price": 600,
"total_price": 600,
"tax_cents": 78
},
{
"name": "Croissant",
"quantity": 1,
"unit_price": 600,
"total_price": 600,
"tax_cents": 79
}
],
"taxes": [
{
"name": "HST",
"rate": "13.0",
"type": "ADDITIVE",
"amount_cents": 157
}
],
"payments": [
{
"method": "CARD",
"last4": "4242",
"amount_cents": 1357
}
],
"customer": {
"paperless_customer_id": "3f0e2c1a-7d33-4f0a-9b9b-ac3b5b1c2f88",
"email": null,
"phone": null
}
}
2.2 Response
| Status | Body | Meaning |
|---|---|---|
201 Created | {"receipt_id": "...", "matched_customer": true} | New receipt stored. |
200 OK | {"receipt_id": "...", "matched_customer": true, "duplicate": true} | Duplicate external_id — already stored. Safe to ignore. |
400 Bad Request | Validation error | Schema problem. Do not retry; fix and re-send. |
401 Unauthorized | — | Bad API key or bad signature. |
409 Conflict | Validation error | Same external_id with conflicting data. Do not retry. |
429 Too Many Requests | — | Honor Retry-After. |
5xx | — | Transient. Retry per the schedule below. |
3. Signing the request
Paperless verifies signatures using the same scheme described on the webhook contract page, but in the opposite direction — you sign and Paperless verifies.
canonical_string = X-Paperless-Timestamp + "." + raw_request_body signature = HMAC-SHA256(SIGNING_SECRET, canonical_string) X-Paperless-Signature = hex(signature)
3.1 Python reference
import hashlib
import hmac
import json
import time
import requests
def push_receipt(receipt: dict) -> dict:
body = json.dumps(receipt, separators=(",", ":"), sort_keys=True).encode("utf-8")
timestamp = str(int(time.time()))
canonical = timestamp.encode("utf-8") + b"." + body
signature = hmac.new(
SIGNING_SECRET.encode("utf-8"),
canonical,
hashlib.sha256,
).hexdigest()
response = requests.post(
f"https://api.paperless.example/ingest/{PROVIDER}/receipts",
data=body,
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"X-Paperless-Signature": signature,
"X-Paperless-Timestamp": timestamp,
"Idempotency-Key": receipt["external_id"],
},
timeout=5,
)
response.raise_for_status()
return response.json()
3.2 cURL
BODY=$(cat receipt.json)
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" | awk '{print $2}')
curl -sS -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "X-Paperless-Signature: $SIG" \
-H "X-Paperless-Timestamp: $TS" \
-H "Idempotency-Key: ord_ABC123XYZ" \
--data-binary "$BODY" \
"https://api.paperless.example/ingest/$PROVIDER/receipts"
4. Retry strategy
You own retries in push mode (there is no webhook layer between you and the receipt store). On a non-2xx response that is retry-eligible (5xx or 429), retry up to 3 times with exponential backoff:
| Attempt | Wait before next try |
|---|---|
| 1 (initial) | 30 s |
| 2 | 60 s |
| 3 (final) | DLQ on your side; alert ops. |
Each retry must use the same external_id and the same body. Re-sign with a
fresh X-Paperless-Timestamp on every attempt.
5. Customer identification
In push mode, the customer is identified by one of three things on the receipt body:
customer.paperless_customer_id— the barcode value the cashier captured at checkout. Strongest signal.customer.email— must match exactly an email Paperless has on file.customer.phone— E.164 format. Must match exactly.
Send whichever you have. Paperless tries them in order. If none match, the receipt is stored unmatched and will surface to the customer if they later attach the merchant.
See customer matching for the full resolution chain.
6. Limitations vs Pattern A
- No automatic location sync — your locations are embedded in each receipt.
- No POS-side mapping cache — same email/phone customer is matched every time (slightly slower).
- Schema is locked. New optional fields require coordinated releases with Paperless.
- If you ever need order-level enrichment after the fact (refunds, returns), you must POST a new event referencing the original
external_id. See the refund schema on payload schema.
Implementation checklist
- [ ] Secrets stored in a vault (signing secret + API key).
- [ ] Receipt builder emits the full JSON schema from payload schema.
- [ ] HMAC signing implemented and matches the validator endpoint (see testing).
- [ ] Retry loop with exponential backoff and DLQ.
- [ ] Cashier flow captures the Paperless barcode and writes
paperless_customer_idon the receipt. - [ ] Sandbox run-through confirms a test receipt reaches a test customer's app.