Developers

API Integration

Pantri Partner Integration Specification — provision category-restricted benefits, capture proof-of-spend, and receive signed lifecycle webhooks.

v1.0 — production-readyAudience: partner engineering teams implementing the integration.
Base URL (prod): https://api.pantri.online
Base URL (sandbox/VM): http://169.255.58.111:8067

1. 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:

  1. Bearer token in Authorization: Pantri-Partner <token>. Issued once per environment, shown exactly once, stored as a SHA-256 hash on our side.
  2. HMAC-SHA256 signature in X-Pantri-Signature, computed as hex(HMAC_SHA256(secret, timestamp + "." + nonce + "." + body)). timestamp is Unix epoch seconds (string), nonce is a UUIDv4 hex string (≥16 URL-safe chars), and body is the raw request bytes.
  3. Timestamp window in X-Pantri-Timestamp — within ±300 seconds of Pantri's clock. Outside the window → 400.
  4. Nonce uniqueness in X-Pantri-Nonce — a nonce seen from this partner within 2× the window → 409. Use a fresh UUIDv4 per request.
  5. 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).

Operational note: Pantri's 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

POST/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

FieldTypeReqNotes
external_policy_idstring ≤128yesYour policy identifier. Unique per partner.
external_claim_idstring ≤128noYour claim identifier, stored for cross-reference.
amountdecimalyesTotal grant in ZAR. ≥ 0.01. Subject to per-partner cap.
categoriesstring[]noOne or more of: food, medication, hygiene, transport, funeral_supplies, general. Empty = unrestricted (general). Each must be in the partner's allow-list.
category_sub_budgetsdictnoCaps spend within a category, in addition to the overall amount.
expires_in_daysint (1–365)noDefault 90. After this, unspent funds are released.
policy_holder.*stringsrec.Populates/updates PartnerPolicy. PII encrypted at rest.
beneficiary.*stringsfull_nameAt least full_name. Email/phone enables idempotent re-resolution.
metadataobjectnoFree-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

HTTPBodyMeaning
400missing fields, bad category, invalid sub-budgetPayload validation
401missing/malformed auth, signature mismatch, unknown credentialAuth failed
403IP not allowed, integration disabled, category not allowed, per-grant capAuthorization failed
409nonce reuse detectedReplay attempt
429daily grant total cap exceededRate limit
500internal errorOpen a ticket

3.2 Cancel a grant

POST/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

GET/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

GET/partners/v1/grants/<grant_uid>/

Same payload shape as the claim response. Useful for polling.

3.5 Health / connectivity check

GET/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

EventFires whenTransition
grant.fundedA claim is provisioned→ funded
grant.partially_redeemedFirst debit against a funded grant (not re-fired on later debits)funded → partially_redeemed
grant.fully_redeemedA debit empties the grant* → fully_redeemed
grant.cancelledPartner calls cancel* → cancelled
grant.expiredHourly sweep finds a grant past expiry with a balancefunded/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

  1. Verify the signature — same HMAC scheme as inbound, over timestamp + "." + nonce + "." + body, using the shared secret (one secret, two directions).
  2. Check the timestamp window (±300s).
  3. Dedup on X-Idempotency-Key — retries keep the same key; process exactly once.
  4. 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

AttemptDelay before attempt
1 (initial)0 (on parent transaction commit)
21 minute
35 minutes
430 minutes
52 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:

  1. Signs up / logs in to Pantri (email + OTP).
  2. POSTs {"claim_token": "<from url>"} to POST /partners/beneficiary/claim/ with a Bearer JWT.
  3. 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

  1. Pantri creates a PartnerOrganization (e.g. slug=partner, or partner-sandbox for staging).
  2. Pantri rotates a webhook secret via the admin action — plaintext shared once, out of band. Store in your secrets manager.
  3. Pantri mints a PartnerApiCredential with scopes ["claims:write", "reports:read"] and shares the token once.
  4. Pantri sets allowed_categories (default ["food", "medication", "hygiene", "transport"]).
  5. Optional: Pantri sets inbound_ip_allowlist to your egress CIDR.
  6. You verify GET /partners/v1/health/ returns 200.
  7. 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 PartnerAuditLog with 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