Testing checklist
A staged path from "first signed request reaches Paperless" to "merchant goes live." Do not skip stages — every stage exists because a partner has historically tripped on something at that point.
Stage 0 — Environments and credentials
| Env | Base URL | What it does |
|---|---|---|
| Sandbox | https://api.sandbox.paperless.example | Functional testing. No real merchants. Receipts visible only to test customer accounts you provision. |
| Staging | https://api.staging.paperless.example | Pre-prod parity. Real merchant + real customer end-to-end. Required before go-live. |
| Production | https://api.paperless.example | Live. |
Confirm before you write any code:
- [ ] Your solutions engineer has issued sandbox
providerslug,WEBHOOK_SIGNING_SECRET, and (Pattern B)API_KEY. - [ ] Secrets are stored in your vault, not in env files.
- [ ] At least one test customer account has been created on the sandbox Paperless mobile app and its
paperless_customer_idis in your possession.
Stage 1 — Signature validator
Before sending real receipts, validate your HMAC implementation against the Paperless debug endpoint. It accepts any body and tells you whether the signature is correct.
POST https://api.sandbox.paperless.example/debug/verify-signature
Content-Type: application/json
X-Paperless-Signature: <your hex signature>
X-Paperless-Timestamp: <unix seconds>
X-Paperless-Provider: acme
{ "any": "payload" }
| Response | Meaning |
|---|---|
200 {"valid": true} | You're good. |
200 {"valid": false, "reason": "bad_signature"} | HMAC mismatch. |
200 {"valid": false, "reason": "skew"} | Timestamp drift > 5 min. |
404 | Unknown provider slug. |
Common failure modes
- Pretty-printed body — signed bytes ≠ sent bytes. Fix: serialize once, sign that buffer, send that buffer.
- Hex casing — Paperless accepts lowercase only. Convert with
.toLowerCase(). - Clock drift — install NTP.
chronydorsystemd-timesyncdon Linux; built-in on macOS. - Secret encoding — pass the secret as bytes, exactly as given. Don't base64-decode.
Stage 2 — Single test webhook (Pattern A) / single test push (Pattern B)
Pattern A
BODY='{"external_event_id":"evt_smoke_001","event_type":"order.completed","created_at":"2026-05-15T17:21:19Z","merchant_id":"MERCHANT_SANDBOX_1","location_id":"LOC_001","data":{"order_id":"ord_smoke_001"}}'
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.sandbox.paperless.example/webhooks/acme"
Expected: 200 {"ok": true}. Then within ~30 s, Paperless calls your sandbox GET /v1/orders/ord_smoke_001. Have a hand-rolled fixture ready that returns the order in the schema from payload schema.
Pattern B
POST a full receipt to /ingest/acme/receipts per Pattern B. Expected: 201 {"receipt_id": "...", "matched_customer": true} if you included the test customer's paperless_customer_id.
Stage 3 — End-to-end with a real test customer
- Sign into the sandbox Paperless mobile app with your test customer account.
- Display the barcode. Note the
PL-<uuid>payload. - Ring a transaction in your sandbox POS, scan the barcode, complete the sale.
- Wait ≤ 5 s, open the mobile app's receipt inbox. The receipt should appear.
- Verify on the receipt: line items, taxes, tenders, merchant, location all correct.
Stage 4 — Edge cases
Cover each of these against sandbox before staging:
| Scenario | Expected behavior |
|---|---|
Duplicate external_event_id | 200 {"ok": true, "duplicate": true}. No second receipt. |
| Bad signature | 401. No event stored. |
| Skewed timestamp | 401. |
Unknown provider | 404. |
| Order with no barcode, unknown customer | Receipt stored unmatched. Customer must claim. |
| Order with no barcode, known customer (email match) | Receipt matched on email. Mapping saved (Pattern A). |
Refund event for unknown order_id | 400 in Pattern A (event accepted but refund dropped). In B you must wait for receipt to exist first. |
| Multiple tenders (split tender) | Receipt shows both. Sum equals total. |
Cash-only sale (no last4) | Receipt shows tender method = CASH. |
| Refund-then-resend (idempotency) | Only one refund applied. |
| Re-deliver same event 5 minutes later | Deduped. No-op. |
| Token expires mid-flight (Pattern A) | Order fetch returns 401, Paperless refreshes, retries, succeeds. |
Stage 5 — Load test
- Replay 1,000 signed webhook events back-to-back from a single thread. Confirm no rejections.
- Replay the same 1,000 events again. Confirm all return
"duplicate": trueand no second receipts appear. - If your peak hour exceeds 50 events/s/merchant, ask ops to raise the sandbox rate-limit ceiling for the test.
Stage 6 — Staging dry run
Staging mirrors production. Repeat stages 3 and 4 against one real pilot merchant. Do not connect any live merchants until staging is green.
Stage 7 — Go-live
- [ ] Production signing secret rotated in. Stage-1 validator green against production.
- [ ] Production URL configured on your webhook emitter.
- [ ] On-call runbook on your side names the contact for Paperless ops.
- [ ] First production merchant connected. First production receipt verified in the mobile app within 10 minutes.
- [ ] Alerting on your side: alert when > 1 % of webhook deliveries hit DLQ in any 5-minute window.
Sample signed-webhook fixture
Copy this exact fixture to confirm your verifier matches Paperless's. Secret: test_secret_do_not_use_in_prod.
Body:
{"external_event_id":"evt_fixture_1","event_type":"order.completed","created_at":"2026-05-15T17:21:19Z","merchant_id":"MERCHANT_FIXTURE","location_id":"LOC_FIXTURE","data":{"order_id":"ord_fixture_1"}}
Timestamp:
1747329679
Canonical string:
1747329679.{...the body above, byte-for-byte...}
Expected signature (hex, lowercase):
<run the canonical string through HMAC-SHA256 with the secret; your value should match what
Paperless returns on the /debug/verify-signature endpoint.>
Troubleshooting
| Symptom | Probable cause |
|---|---|
401 on every request | Pretty-printed JSON, wrong secret, clock skew, or hex case. |
200 on webhook but no receipt in app | Pattern A order fetch is failing — check Paperless logs (ask ops) and your /v1/orders/{id} endpoint. |
| Receipt appears with wrong customer | Email/phone fallback matched a different customer with the same contact. Use the barcode instead. |
| Receipt appears unmatched repeatedly | Custom attribute field name doesn't match. Confirm with ops; the agreed-upon field name must be paperless_customer_id in Pattern A. |
| Receipt has wrong tax breakdown | Tax name may not match GST / HST recognition list; relevant only for CA reporting. |
| Receipt totals are off by 1 cent | subtotal + tax ≠ total. Paperless requires strict equality. |