API Integration
Pantri Partner Integration Specification — provision category-restricted benefits, capture proof-of-spend, and receive signed lifecycle webhooks.
https://api.pantri.onlinehttp://169.255.58.111:80671. What this integration does
When a partner approves a funeral-cover claim and releases funds to a bereaved family, the partner POSTs a signed webhook to Pantri. Pantri provisions a category-restricted benefit (food, medication, hygiene, transport — whatever the partner specifies) onto a Pantri wallet for the named beneficiary, then returns a single-use claim URL the family follows to onboard.
The beneficiary spends the benefit at participating Pantri retailers (initial pilot: SKUBU). Every spend is debited from the grant, restricted to the authorised categories, and recorded for proof-of-spend.
The partner polls (or receives outbound webhooks for) lifecycle events: funded → partially_redeemed → fully_redeemed / expired / cancelled.
2. Security model
Every inbound mutation must satisfy all five of:
- Bearer token in
Authorization: Pantri-Partner <token>. Issued once per environment, shown exactly once, stored as a SHA-256 hash on our side. - HMAC-SHA256 signature in
X-Pantri-Signature, computed ashex(HMAC_SHA256(secret, timestamp + "." + nonce + "." + body)).timestampis Unix epoch seconds (string),nonceis a UUIDv4 hex string (≥16 URL-safe chars), andbodyis the raw request bytes. - Timestamp window in
X-Pantri-Timestamp— within ±300 seconds of Pantri's clock. Outside the window → 400. - Nonce uniqueness in
X-Pantri-Nonce— a nonce seen from this partner within 2× the window → 409. Use a fresh UUIDv4 per request. - Source IP (optional) — if an IP allowlist is configured, the request must originate from a CIDR in the list.
If you also send X-Idempotency-Key, replays of the same request body return the original response. Always send an idempotency key on mutations (claims and cancellations).
webhook_secret rotates from the Pantri admin (/admin/partners/partnerorganization/). On rotation we notify the partner via the agreed channel and run a brief overlap window.2.1 Reference signature implementation (Python)
import hashlib, hmac, json, time, uuid
import requests
PARTNER_TOKEN = "pntpartner_…" # from Pantri
PARTNER_SECRET = "…" # from Pantri
def post_signed(url: str, body: dict, idempotency_key: str) -> requests.Response:
raw = json.dumps(body, separators=(",", ":")).encode("utf-8")
ts = str(int(time.time()))
nonce = uuid.uuid4().hex
mac = hmac.new(PARTNER_SECRET.encode(), digestmod=hashlib.sha256)
mac.update(ts.encode()); mac.update(b".")
mac.update(nonce.encode()); mac.update(b".")
mac.update(raw)
sig = mac.hexdigest()
return requests.post(
url, data=raw, timeout=15,
headers={
"Content-Type": "application/json",
"Authorization": f"Pantri-Partner {PARTNER_TOKEN}",
"X-Pantri-Signature": sig,
"X-Pantri-Timestamp": ts,
"X-Pantri-Nonce": nonce,
"X-Idempotency-Key": idempotency_key,
},
)3. Endpoints
All paths are relative to the Pantri base URL.
3.1 Provision a benefit
/partners/v1/claims/Use when: a claim has been approved and funds are being released to a beneficiary.
Request body
{
"external_policy_id": "POL-12345",
"external_claim_id": "CLM-99887",
"amount": "2500.00",
"categories": ["food", "medication"],
"category_sub_budgets": { "food": "1500.00", "medication": "1000.00" },
"expires_in_days": 90,
"policy_holder": {
"name": "Themba Holder",
"id_number": "8001011234080",
"email": "holder@example.com",
"phone": "+27821234567"
},
"beneficiary": {
"full_name": "Naledi Holder",
"id_number": "8504201234080",
"email": "naledi@example.com",
"phone": "+27827654321",
"relationship": "spouse"
},
"policy_type": "family funeral cover",
"cover_amount": "25000.00",
"metadata": {
"partner_claim_url": "https://partner.example.com/claims/99887",
"claim_approver": "agent-42"
}
}Field rules
| Field | Type | Req | Notes |
|---|---|---|---|
external_policy_id | string ≤128 | yes | Your policy identifier. Unique per partner. |
external_claim_id | string ≤128 | no | Your claim identifier, stored for cross-reference. |
amount | decimal | yes | Total grant in ZAR. ≥ 0.01. Subject to per-partner cap. |
categories | string[] | no | One or more of: food, medication, hygiene, transport, funeral_supplies, general. Empty = unrestricted (general). Each must be in the partner's allow-list. |
category_sub_budgets | dict | no | Caps spend within a category, in addition to the overall amount. |
expires_in_days | int (1–365) | no | Default 90. After this, unspent funds are released. |
policy_holder.* | strings | rec. | Populates/updates PartnerPolicy. PII encrypted at rest. |
beneficiary.* | strings | full_name | At least full_name. Email/phone enables idempotent re-resolution. |
metadata | object | no | Free-form; surfaced on admin + reports. |
Headers (all required for mutations)
Authorization: Pantri-Partner <token>
Content-Type: application/json
X-Pantri-Signature: <hex>
X-Pantri-Timestamp: <unix seconds>
X-Pantri-Nonce: <uuid4 hex>
X-Idempotency-Key: <uuid4>Success — 201 Created (first call) or 200 OK (replay)
{
"grant_uid": "20c945cf-…",
"status": "funded",
"amount": "2500.00",
"redeemed_amount": "0.00",
"remaining_amount": "2500.00",
"categories": ["food", "medication"],
"category_sub_budgets": {"food": "1500.00", "medication": "1000.00"},
"funded_at": "2026-06-25T12:43:09Z",
"expires_at": "2026-09-23T12:43:09Z",
"beneficiary_uid": "…",
"policy_uid": "…",
"external_claim_id": "CLM-99887",
"claim_token": "OSQUfOiE…",
"claim_url": "https://api.pantri.online/claim/OSQUfOiE…/"
}claim_url is what the partner sends the family — by SMS, email or WhatsApp — so they can open Pantri and pick up the funded benefit.
Errors
| HTTP | Body | Meaning |
|---|---|---|
| 400 | missing fields, bad category, invalid sub-budget | Payload validation |
| 401 | missing/malformed auth, signature mismatch, unknown credential | Auth failed |
| 403 | IP not allowed, integration disabled, category not allowed, per-grant cap | Authorization failed |
| 409 | nonce reuse detected | Replay attempt |
| 429 | daily grant total cap exceeded | Rate limit |
| 500 | internal error | Open a ticket |
3.2 Cancel a grant
/partners/v1/claims/cancel/Use when: a claim is reversed (fraud, duplicate, customer request). Cancelling a fully-redeemed grant is a no-op.
{
"grant_uid": "20c945cf-…",
"reason": "Claim withdrawn by customer per ticket SUP-1234"
}Success — 200 OK. Same payload shape as the claim response; status flips to cancelled. Any unredeemed funds become unavailable to the beneficiary immediately.
3.3 Proof-of-spend disbursements
/partners/v1/policies/<external_policy_id>/disbursements/Lists every grant + every debit/reversal under one policy.
{
"policy_uid": "…",
"external_policy_id": "POL-12345",
"grants": [
{ "grant_uid": "…", "status": "partially_redeemed", "amount": "2500.00",
"redeemed_amount": "300.00", "remaining_amount": "2200.00", … }
],
"disbursements": [
{
"disbursement_uid": "…",
"grant_uid": "20c945cf-…",
"amount": "300.00",
"category": "food",
"direction": "debit",
"description": "Skubu basket",
"occurred_at": "2026-06-25T13:18:22Z"
}
]
}3.4 Single-grant status
/partners/v1/grants/<grant_uid>/Same payload shape as the claim response. Useful for polling.
3.5 Health / connectivity check
/partners/v1/health/Returns {"ok": true, "partner_slug": "...", "credential_uid": "..."} when fully authorized. Use it as a CI canary.
4. Outbound webhooks (Pantri → partner)
live When PartnerOrganization.webhook_callback_url is set, Pantri POSTs signed lifecycle events to that URL whenever a grant transitions state.
4.1 Events
| Event | Fires when | Transition |
|---|---|---|
grant.funded | A claim is provisioned | → funded |
grant.partially_redeemed | First debit against a funded grant (not re-fired on later debits) | funded → partially_redeemed |
grant.fully_redeemed | A debit empties the grant | * → fully_redeemed |
grant.cancelled | Partner calls cancel | * → cancelled |
grant.expired | Hourly sweep finds a grant past expiry with a balance | funded/partially_redeemed → expired |
Multiple debits inside partially_redeemed do not re-fire the event — it is per-transition, not per-debit.
4.2 Request shape the partner receives
POST <your webhook_callback_url> HTTP/1.1
Content-Type: application/json
User-Agent: Pantri-Webhook/1.0
X-Pantri-Event-Type: grant.fully_redeemed
X-Pantri-Event-Uid: 7c0c4a0e-… (stable per delivery)
X-Pantri-Signature: <hex hmac>
X-Pantri-Timestamp: 1782392861 (unix seconds)
X-Pantri-Nonce: <uuid4 hex> (fresh per attempt)
X-Idempotency-Key: <stable per envelope> (same across retries)
{
"event": "grant.fully_redeemed",
"event_id": "f00f…",
"occurred_at": "2026-06-25T13:14:09Z",
"grant": {
"grant_uid": "20c945cf-…",
"status": "fully_redeemed",
"amount": "500.00",
"redeemed_amount": "500.00",
"remaining_amount": "0.00",
"categories": ["food", "medication"],
"category_sub_budgets": {"food": "300.00", "medication": "200.00"},
"funded_at": "2026-06-25T13:01:21Z",
"expires_at": "2026-09-23T13:01:21Z",
"fully_redeemed_at": "2026-06-25T13:14:09Z",
"cancelled_at": null,
"cancellation_reason": null,
"external_claim_id": "CLM-99887",
"idempotency_key": "<partner-provided>"
},
"policy": { "policy_uid": "…", "external_policy_id": "POL-12345" },
"beneficiary": {
"beneficiary_uid": "…",
"full_name": "Naledi Holder",
"claim_token": "OSQUfOiE…",
"claim_url": "https://api.pantri.online/claim/OSQUfOiE…/"
}
}4.3 What the partner must do on receipt
- Verify the signature — same HMAC scheme as inbound, over
timestamp + "." + nonce + "." + body, using the shared secret (one secret, two directions). - Check the timestamp window (±300s).
- Dedup on
X-Idempotency-Key— retries keep the same key; process exactly once. - Reply 2xx within 10 seconds — anything else (or a timeout) triggers a retry.
import hashlib, hmac, time
def verify_pantri_webhook(secret: str, request) -> bool:
sig = request.headers.get('X-Pantri-Signature', '')
ts = request.headers.get('X-Pantri-Timestamp', '')
nonce = request.headers.get('X-Pantri-Nonce', '')
body = request.get_data() # raw bytes — do not re-serialize JSON
if not (sig and ts and nonce):
return False
if abs(int(time.time()) - int(ts)) > 300:
return False
mac = hmac.new(secret.encode(), digestmod=hashlib.sha256)
mac.update(ts.encode()); mac.update(b'.')
mac.update(nonce.encode()); mac.update(b'.')
mac.update(body)
return hmac.compare_digest(mac.hexdigest(), sig)4.4 Retry policy
| Attempt | Delay before attempt |
|---|---|
| 1 (initial) | 0 (on parent transaction commit) |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 (final) | 12 hours |
After the 6th attempt the envelope is parked in delivery_failed for manual review (worst-case ≈14h45m). A safety-net sweep (partners.tasks.retry_due_outbound_webhooks) runs every 5 minutes, so worst-case retry lag is the scheduled delay + 5 min. Manual re-queue: /admin/partners/partnerwebhookevent/.
4.5 What Pantri persists
Every attempt — send, retry, failure — writes to PartnerWebhookEvent with direction='outbound', carrying the signed request body, the response status + body (truncated to 8 KB), and the running retry_count. Disputes become a single-row lookup.
5. Beneficiary onboarding (no partner integration needed)
The bereaved family follows claim_url and:
- Signs up / logs in to Pantri (email + OTP).
- POSTs
{"claim_token": "<from url>"}toPOST /partners/beneficiary/claim/with a Bearer JWT. - Sees the funded grant and redeems at any participating retailer (Skubu first).
The Pantri app handles all of this — the partner does not implement step 3.
6. Sandbox onboarding
- Pantri creates a
PartnerOrganization(e.g.slug=partner, orpartner-sandboxfor staging). - Pantri rotates a webhook secret via the admin action — plaintext shared once, out of band. Store in your secrets manager.
- Pantri mints a
PartnerApiCredentialwith scopes["claims:write", "reports:read"]and shares the token once. - Pantri sets
allowed_categories(default["food", "medication", "hygiene", "transport"]). - Optional: Pantri sets
inbound_ip_allowlistto your egress CIDR. - You verify
GET /partners/v1/health/returns 200. - You send a small test claim; we walk the beneficiary onboarding together once.
7. Compliance posture
- PII at rest — beneficiary + policy-holder ID numbers encrypted via Fernet (
settings.FERNET_KEY). DB leaks don't surrender raw ID numbers without the app key. - Webhook secrets at rest — same Fernet envelope; rotation is one admin click, writing new ciphertext atomically.
- Audit log — every action lands in
PartnerAuditLogwith a SHA-256 hash chain; tampering invalidates subsequent rows and surfaces on the nightly integrity sweep. - POPIA — beneficiaries can request their data and deletion via Pantri's standard flow; grant rows persist (anonymised) for the policy lifetime as required by funeral-policy reporting.
- Replay protection — HMAC + timestamp window + nonce dedup + body digest.
- Idempotency — mandatory on mutations via
X-Idempotency-Key; replays return the original response. No double-funding.
8. Versioning & change policy
- The path prefix
/partners/v1/is stable. Breaking changes land on/partners/v2/with ≥90 days' parallel running. - New fields may be added to existing payloads without a version bump — clients must ignore unknown fields.
- At least 30 days' notice before tightening any request validation.
9. Contacts
- Partner integrations engineering: integrations@pantri.online
- Operations / on-call: ops@pantri.online
- Sandbox status / change log:
https://api.pantri.online/partners/changelog/