Note: Rail code status deferred — superseded in priority by Flutterwave for multi-country Africa expansion. Keep this document as a reference; do not delete. Commercial section 1 (access prerequisites + tenant onboarding runbook) has been documented and the public tenant runbook is published (see Implementation TODOs §1); gateway/Harbor/console code remains deferred.
Date: 2026-07-14 (commercial section 1 documented 2026-07-15)
Status: Implementation design (rail not yet coded); commercial access prerequisites documented and tenant onboarding runbook published
Decision: Paystack is a self-serve, BYO per-tenant rail (Adyen parity). Each tenant creates its own Paystack merchant account, obtains its own keys, and completes its own live KYB with Paystack. Paybond does not file a platform-level KYB partnership. TrueLayer/Wise deferred pending demand.
Not legal advice — commercial counsel must review Paystack commercial terms, custodial posture, and AML exposure before Paybond enables live tenant destinations, and each tenant completes their own Paystack merchant KYB.
Executive summary
- Rail id:
paystack_nip - Primary funding shape: Dedicated Virtual Account (VA) credit → webhook evidence → Transfer (NIP) release to payee / reverse refund path.
- Webhook job kind:
paystack_settlement - Destination table:
paybond_tenant_paystack_destination - Environments: sandbox (test secret keys, self-serve) and live (live secret keys, gated behind each tenant's own Paystack KYB). Both sandbox and live are self-serve per tenant.
Paystack is comparatively self-serve for merchant onboarding and Dedicated Virtual Account (DVA) provisioning: a tenant signs up for their own Paystack merchant account, collects their own test/live secret_key from the Paystack dashboard, and completes their own live KYB with Paystack to unlock live keys. Paybond is strictly bring-your-own-credentials (BYO) — a tenant admin pastes the secret_key into Console Settlement (write-only), and webhook verification uses HMAC-SHA512 over the raw body with that same secret_key (X-Paystack-Signature; there is no separate webhook secret). Implementation follows Paybond's control-plane pattern: Gateway holds the tenant secret_key in the vault, verifies inbound webhooks and tenant binding, enqueues durable webhook jobs; Harbor performs money-movement calls using tenant-scoped credentials and enforces tenant isolation and terminal converge rules.
Engineer access & credentials (self-serve)
- Sandbox is self-serve: Paybond engineering (for E2E) and each tenant obtain a test
secret_keyfrom their own Paystack dashboard and enable Dedicated Virtual Account testing for NGN — no Paybond-brokered partnership is required. See Implementation TODOs §1 (paystack_commercial_sandbox_prep) for the concrete checklist. - Live requires the tenant's own Paystack merchant KYB/onboarding (production ship blocker for that tenant's live keys). Paybond does not centrally file KYB on tenants' behalf.
- Only the tenant
secret_keyis stored (encrypted). It is used both for outbound API auth and to verify inboundX-Paystack-SignatureHMAC-SHA512 — do not invent a separate webhook secret.
Fund → evidence → release / reverse flow (VA → Transfer)
- Create intent (rail=
paystack_nip) — Gateway validates tenant and snapshots destination config; Harbor creates provider-side VA mapping or requests a per-intent VA. For v1 we recommend per-intent VAs (VA-per-intent) to keep tenant bindings simple for multi-tenant escrow; VA assignment is asynchronous and may emitdedicatedaccount.assign.successwhen assignment completes. - Buyer pays into Dedicated Virtual Account (VA) number / beneficiary — Paystack credits the VA (NUBAN-like number).
- Paystack emits webhook for VA credit. The canonical fund gate is
charge.successwhereauthorization.channel == "dedicated_nuban". Gateway verifies webhook HMAC (X-Paystack-Signature, HMAC-SHA512 using the tenant'ssecret_key) and resolvesaccount_number/metadata→ tenant binding. - Gateway enqueues
paystack_settlementwebhook job; Harbor ingests internal eventPOST /internal/v1/paystack/eventscontaining tenant and original webhook. - Harbor marks intent
fundedonce fund gate (credit confirmed) and records provider ids (paystack_va_id,paystack_transaction_id). - Operator / App submits evidence. If evidence passed:
- Harbor executes Transfer (NIP) via Paystack Transfer API to payee bank.
- Record
paystack_transfer_id; leave intent in pending-terminal until transfer lifecycle webhooks confirm final state. - Paystack emits transfer lifecycle webhooks such as
transfer.success,transfer.failed, andtransfer.reversed. Ontransfer.success→ markreleased. On failure/reversal →manual_review/retryper policy.
- If evidence failed:
- Harbor initiates reverse flow: either issue a transfer reversal (when supported) or create a refund via Paystack's refund patterns. Paystack emits refund webhooks (e.g.,
refund.processed,refund.failed); map torefundedonly after webhook confirmation.
- Harbor initiates reverse flow: either issue a transfer reversal (when supported) or create a refund via Paystack's refund patterns. Paystack emits refund webhooks (e.g.,
Notes:
- Prefer snapshotting beneficiary on intent create and never trust live client-supplied payee bank details during terminal payout.
- Use Paystack
metadataon transaction creation to includepaybond_intent_idandpaybond_tenant_idfor binding and reconciliation.
Architecture (Mermaid)
sequenceDiagram
autonumber
actor Admin as Tenant admin
participant GW as Gateway
participant PG as Postgres
participant HB as Harbor
participant PS as Paystack API
actor Buyer as Buyer (payer bank / VA credit)
actor App as Kit / application
Admin->>GW: POST /v1/admin/settlement/paystack/destination (upsert secrets and VA config)
GW->>PG: Encrypt secret_key, store account mapping / webhook token
App->>GW: POST /harbor/intents (rail=paystack_nip)
GW->>HB: Forward create (mTLS)
HB->>PG: Snapshot destination and payee beneficiary
HB->>PS: (optional) Ensure VA exists or provision VA; for v1 prefer per-intent VA assignment. VA assignment is asynchronous and may surface `dedicatedaccount.assign.success`; create expected VA metadata.
HB-->>App: intent created / instructions (display VA account number)
Buyer->>PS: Pay into VA (bank transfer)
PS-->>GW: Webhook (VA credited) with `X-Paystack-Signature`
GW->>GW: Verify HMAC signature, bind account_number -> tenant, enqueue paystack_settlement
GW->>HB: POST /internal/v1/paystack/events (tenant and webhook)
HB-->>HB: Fund gate -> funded
App->>GW: evidence and settlement decision
alt evidence passed
HB->>PS: POST /transfer (NIP) to payee bank (tenant credentials)
PS-->>GW: transfer.success webhook
HB-->>HB: released
else evidence failed
HB->>PS: Refund / reverse per Paystack guidance
PS-->>GW: refund/transfer_failed webhook
HB-->>HB: refunded
endSQL schema and migrations
Suggested migration filenames (mirror Adyen pattern with date prefix):
go/gateway/sql/migrations/202607XX_paystack_destination.sqlgo/gateway/sql/migrations/202607XX_paystack_webhook_job_kind.sqlgo/gateway/sql/migrations/202607XX_paystack_inactive_binding_retention.sqlgo/gateway/sql/migrations/202607XX_paystack_destination_secret_purge.sql
Destination table sketch:
CREATE TABLE paybond_tenant_paystack_destination ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id uuid NOT NULL REFERENCES paybond_tenant (id) ON DELETE CASCADE, secret_key_encrypted text, -- write-only ciphertext for secret_key account_number text, -- VA account number or provisioning template va_id text, -- Paystack VA id when pre-provisioned webhook_uri_token text NOT NULL, -- unguessable token for URL binding environment text NOT NULL CHECK (environment IN ('sandbox', 'live')), status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','inactive')), deactivated_at timestamptz, secrets_purged_at timestamptz, label text NOT NULL DEFAULT '', currency text NOT NULL DEFAULT 'NGN' CHECK (currency = 'NGN'), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (tenant_id, environment) ); CREATE UNIQUE INDEX paybond_tenant_paystack_destination_active_uidx ON paybond_tenant_paystack_destination (tenant_id) WHERE status = 'active'; ALTER TABLE paybond_tenant_paystack_destination ENABLE ROW LEVEL SECURITY;
Extend paybond_webhook_job.job_kind CHECK to include 'paystack_settlement'.
Idempotency key suggestion:
paystack:{environment}:{event_id}
Fallback: paystack:{environment}:{type}:{transaction_id}:{status} when event_id absent.
Gateway implementation checklist (mirror Adyen / TrueLayer parity)
-
Packages / files:
go/gateway/internal/paystackwebhook/— HMAC verify, max-age, binding, processgo/gateway/internal/paystacksecretpurge/— inactive secret purge workergo/gateway/internal/paystackmap/— account_number/va_id → tenant lookup helpersgo/gateway/internal/db/settlement_paystack_destination_custom.go— vault CRUDgo/gateway/internal/db/settlement_paystack_inventory_custom.go— inventory helpersgo/gateway/internal/httpserver/settlement_paystack_destination.go— Admin HTTP handlers (upsert, disconnect)go/gateway/internal/httpserver/settlement_paystack_destination_test.go— unit/integration testsgo/gateway/internal/httpserver/settlement_paystack_destination_integration_test.go— admin upsert and disconnect integration test (parity)go/gateway/internal/httpserver/paystack_webhook_integration_test.go— public webhook ingress integration test (parity with Adyen)go/gateway/internal/httpserver/settlement_paystack_destination_secret_purge_integration_test.go— secret-purge worker integration testgo/gateway/internal/httpserver/paystack_destination_secret_purge_worker.go— purge wiringgo/gateway/internal/webhookjob/enqueue.go— kind and idempotency
-
Admin HTTP APIs:
POST /v1/admin/settlement/paystack/destination— upsert (secret write-only)GET /v1/admin/settlement/paystack/destination— redacted readPOST /v1/admin/settlement/paystack/destination/disconnect— deactivate
-
Public webhook ingress:
POST /webhooks/paystack— compatibilityPOST /webhooks/live/paystackPOST /webhooks/sandbox/paystack
Processing order (ship blockers):
- Read raw body; verify HMAC signature using
X-Paystack-Signatureheader (HMAC-SHA512 of raw payload using configured secret). Fail-closed on verification failure. - Resolve destination via webhook token AND the
account_number/va_idormetadata.client_reference(both must agree). - Max-age guard: apply a conservative window (e.g., 72h inactive binding window for late deliveries) guarded by retention policy.
- Require binding metadata for money-state webhooks; drop unbound events to manual review.
- Enqueue one
paystack_settlementjob and return 2xx quickly. Paystack retries on non-2xx for extended window; ack only after durable enqueue.
Harbor / payments implementation
- Harbor receives internal events at
POST /internal/v1/paystack/events{ tenant, environment, event }. - Harbor performs fund-gate on VA credit events and records provider ids:
paystack_va_id,paystack_transaction_id,paystack_transfer_id. - Terminal release: create Transfer (NIP) using Paystack Transfer API under tenant credentials; leave intent in pending-terminal until Transfer success webhook confirms.
- Refund / reverse: implement transfer-reversal or refund patterns per Paystack docs; map to
refundedonly after webhook confirm. - Always snapshot beneficiary at intent create; do not accept runtime beneficiary changes for terminal payouts.
Webhook verification details
- Paystack webhook signature:
X-Paystack-Signatureheader. Paystack derives the webhook HMAC using HMAC-SHA512 over the raw request body with the merchant's API secret key (secret_key). There is no separate webhook-only secret — use the tenant'ssecret_keyfrom the vault. - Verification steps:
- Read raw request body bytes (do not canonicalize or re-encode).
- Compute HMAC-SHA512(raw_body, secret_key) and compare in constant time to the
X-Paystack-Signatureheader value (follow Paystack docs for expected encoding). - If mismatch -> return 401 / 4xx and log audit; do not process.
- Resolve account binding via
data.account_numberormetadata.client_referenceandwebhook_uri_tokenURL param; both must agree to attribute to tenant. - Enqueue durable job with idempotency keyed on Paystack
event.id(or constructed fallback).
Note: Paystack's official Webhooks guide documents X-Paystack-Signature (HMAC-SHA512) and states the HMAC is computed from the merchant secret_key: https://paystack.com/docs/guides/webhooks/.
Operational notes:
- Paystack retries webhooks on non-2xx responses; implement durable enqueue-before-2xx and idempotent handlers.
- Webhook requests originate from Paystack infrastructure (api.paystack.co / associated IP ranges). Do not rely solely on IP allowlisting — HMAC verification using the
secret_keyis the authoritative trust anchor. Consult Paystack for the current source IP ranges if an allowlist is required.
Reference: Paystack Webhooks / Dedicated Virtual Accounts docs (see Sources).
Secret purge and retention
- Follow Adyen/TrueLayer parity:
- Default purge after 2160h (90 days) inactive for credentials.
- Maintain an inactive binding retention window (default 72h) to allow late-arriving webhook attribution when a destination was recently deactivated.
- Purge only when no
paystack_settlementjobs arepending/processingfor that destination. - On purge: null ciphertext, set
secrets_purged_at, retain audit row; ensure Harbor cannot access purged secrets. - Note: Paystack does NOT provide a separate webhook secret — the API
secret_keyis used to compute theX-Paystack-SignatureHMAC. Store only the tenantsecret_key(encrypted) in the destination vault; webhook verification uses that samesecret_key. Retain inactive-binding windows to allow late-arriving webhooks to be attributed during the retention window.
Rail identifier and naming
| Surface | Value |
|---|---|
| Settlement rail string | paystack_nip |
| Rust enum | SettlementRail::PaystackNip |
| Go const | settlementconfig.RailPaystackNip |
| Webhook job kind | paystack_settlement |
Naming follows adyen_* / truelayer_* → paystack_* pattern.
Security, threat model, and invariants
- Tenant isolation invariants (every Harbor mutation):
- Authenticated automation context carries tenant (mTLS SAN / forward bearer). Never trust webhook tenant identifiers alone.
- Intent load is tenant-scoped; abort on mismatch.
- Destination vault decrypt only for that tenant and matching account_number / va_id snapshot.
- Payout beneficiary must equal create-time snapshot.
- Amount/currency strict equality before payout/refund.
- Severity-zero ship blockers:
- Fail-closed HMAC verification at Gateway.
- Durable outbox-before-2xx for webhook ingestion and idempotent job enqueue.
- Secret purge safety: do not purge when pending jobs reference destination.
- Live KYB / commercial onboarding required before enabling live tenant destinations.
- SSRF / egress allow-list: restrict outbound hostnames used by server-side integrations to Paystack domains (e.g.,
api.paystack.co) and staging sandboxes only; ensure DNS / IPs are resolved and explicit in firewall rules where possible. See Paystack hardening — Egress allow-list for the shipped enforcement (PaystackEnvironment::Customis test-only, never tenant-controlled) and firewall guidance. - Internal request validation: internal endpoints (e.g.,
POST /internal/v1/paystack/events) must deserialize using strict serde settings (deny_unknown_fields) to prevent silent schema drift and dropped fields across services.
Phased delivery
Phase 0 — Design & commercial (blocking)
- Commercial onboarding & KYB with Paystack for live keys (PROD ship blocker).
- Provision Paystack sandbox credentials and test VA provisioning.
- Legal review of VA custodial posture and refund/reversal rules.
Phase 1 — MVP (NGN)
- Migrations and destination vault and settlementconfig rail (
paystack_nip) - Admin console destination upsert and disconnect
- Public webhook ingress and HMAC verify and job kind
paystack_settlement - Harbor internal ingest and VA credit → fund gate
- Evidence-based Transfer (NIP) release driver and pending-terminal reconcile
- Secret purge worker and retention rules
- Kit / Admin parity: CLI ready, admin guide, docs and tests
Exit criteria: sandbox E2E fund (VA credit) → evidence → release (Transfer) / refund; security ship blockers closed; legal KYB started.
Phase 2 — Optimizations
- Pre-provisioned VA lifecycle & reconciliation tooling
- Transfer batching / retry and reconciliation for NIP settlement timing
- Reconcile returned / failed NIP payments and dispute flow
- Support alternate partners (Flutterwave) as expansion
File checklist (gateway / harbor / admin / kit) — parity with Adyen/TrueLayer lists
-
Gateway:
go/gateway/internal/paystackwebhook/*go/gateway/internal/paystacksecretpurge/*go/gateway/internal/paystackmap/*go/gateway/internal/db/settlement_paystack_destination_custom.gogo/gateway/internal/db/settlement_paystack_inventory_custom.gogo/gateway/internal/httpserver/settlement_paystack_destination.go(+ disconnect)go/gateway/internal/httpserver/settlement_paystack_destination_integration_test.go- migrations under
go/gateway/sql/migrations/
-
Harbor / payments:
crates/paybond-payments/src/paystack.rs(tenant-scoped client & transfer helper)crates/harbor-intent-escrow/src/paystack_sync.rs(webhook ingest and converge)crates/harbor-intent-escrow/src/internal_paystack.rs(internal v1 endpoints)crates/harbor-intent-escrow/src/control_plane.rs— control-plane loaders / orchestration (ensure paystack loaders wired)crates/harbor-intent-escrow/src/recovery.rs— recovery paths and retry semantics (parity)- test files:
paystack_vA_test.rs,paystack_transfer_test.rs
-
Admin / Kit / docs:
apps/admin/app/api/settlement/paystack/destination/route.tsapps/admin/guides/configure-paystack-settlement.mdx(new)apps/admin/app/console/configuration/settlement/*wiring (readiness chip, copy webhook URL)apps/admin/lib/settlement-config-wire.ts— wire types / request shapes for admin -> gatewayapps/admin/tests/lib/public-docs-boundary.test.ts— public docs boundary test should include Paystack guide presencekit/ts/src/cli/commands/paystack.ts- Docs:
docs/security/paystack-hardening.md(new) kit/cli-parity/commands.json&kit/cli-parity/contract.json— ensure Paystack CLI parity and blocked-flag ARGV patterns (follow TRUELAYER→PAYSTACK example)
Decisions log (2026-07-14)
- Decision: Implement Paystack as the immediate next international rail for Nigeria (VA and Transfer / NIP). Rationale: demand from Nigeria corridor, clean VA→Transfer escrow mapping, lower initial commercial friction vs raw scheme membership.
- Decision: BYO per-tenant Paystack credentials (Adyen parity) — store the encrypted
secret_keyin the destination vault; Harbor calls Paystack with tenant credentials. There is no separate webhook secret: the samesecret_keyverifies theX-Paystack-SignatureHMAC-SHA512. - Decision: Use Paystack
metadataand VA/account_number binding to attachpaybond_intent_idandpaybond_tenant_id. Gateway must reject unbound webhooks. - Decision: Webhook verify using HMAC-SHA512 (
X-Paystack-Signature) and constant-time compare; fail-closed. - Decision (2026-07-15): Paystack is a self-serve, BYO rail. Each tenant owns its Paystack merchant account, sandbox/live keys, and live KYB; Paybond does not file a platform-partnership KYB.
paystack_commercial_kybis reframed as tenant self-serve KYB (external blocker to unlock that tenant's live keys), with Paybond ops confirming the live-destination gate stays fail-closed.paystack_commercial_contractsis a Paybond-internal legal review. Neither commercial gate is exposed on the public configure guide (mirrors the Flutterwave 2026-07-15 public scrub).
Sources
- Paystack Dedicated Virtual Accounts: https://paystack.com/docs/payments/dedicated-virtual-accounts/
- Paystack Transfers / Payouts: https://paystack.com/docs/api/transfer
- Paystack Webhooks guide: https://paystack.com/docs/guides/webhooks
- Paystack API reference & testing: https://paystack.com/docs/api
Implementation TODOs
Grouped, actionable checklist to allow engineers to begin implementation later (document kept deferred — use paystack_* prefixes).
- Commercial / access prerequisites
Status legend: [x] = deliverable complete in-repo; [ ] = external commercial action (owner and status tracked below). Documentation deliverables are marked complete when shipped; true commercial actions (obtaining real credentials, tenant KYB, legal sign-off) are not marked complete here until the responsible owner executes them outside the repo. Framing: Paystack is self-serve BYO — each tenant owns its own Paystack merchant relationship, keys, and KYB. Paybond does not file a platform-partnership KYB with Paystack.
- Obtain Paystack sandbox credentials and DVA testing access (NGN) —
paystack_commercial_sandbox_prep- Status: Documented, ready to execute (self-serve). Sandbox is self-serve: Paybond engineering (for E2E) and each tenant obtain their own test
secret_keyfrom the Paystack dashboard and enable Dedicated Virtual Account (DVA) testing for NGN — no Paybond-brokered partnership required. The tenant-facing collection steps are in the public guideapps/admin/guides/configure-paystack-settlement.mdx. Actually provisioning real Paystack sandbox credentials is an external step for whoever runs the E2E — no credentials are fabricated in this repo. - Eng checklist (v1, NGN only):
- Create a Paystack account and open the dashboard in test mode; copy the test
secret_key(sk_test_…) from Settings → API Keys & Webhooks. This is the only secret Paybond stores. - Enable Dedicated Virtual Accounts for the account (NGN). DVA/test access may require enabling the feature in the dashboard or contacting Paystack; note that DVA assignment is asynchronous (
dedicatedaccount.assign.success). - Webhook verification uses the same
secret_keyto compute theX-Paystack-SignatureHMAC-SHA512 over the raw body — there is no separate webhook secret to collect. - v1 corridor is NGN only (
currency = 'NGN'); other currencies are out of scope for the first slice.
- Create a Paystack account and open the dashboard in test mode; copy the test
- Owner / next step: settlement onboarding owner (or Paybond eng for E2E) creates a Paystack sandbox account and follows the public guide. Real test keys stay external/blocked until eng actually has them.
- Status: Documented, ready to execute (self-serve). Sandbox is self-serve: Paybond engineering (for E2E) and each tenant obtain their own test
- Start live merchant KYB and onboarding with Paystack (PROD ship blocker) —
paystack_commercial_kyb-
Status: Blocked on commercial (external) — tenant self-serve. This is not a Paybond platform-partnership KYB. Each tenant completes its own Paystack merchant live onboarding/KYB in the Paystack dashboard to unlock its live
secret_key; the first live tenant must finish that before Paybond enables a live Paystack destination. Tracked here only — not exposed on the public configure guide. Live secret keys are only issued by Paystack after the tenant's KYB. -
Owner / next step: tenant admin completes Paystack merchant KYB self-serve (confirm current NGN document requirements in the Paystack dashboard before filing); Paybond ops confirms the live destination gate remains fail-closed (paid-plan + environment checks, and — when the destination table lands — a
kyb_statuscolumn gated like the other rails) before flipping any tenant live. Case ids are tenant-owned and left blank here (no fabrication). -
Tracking table (fill in only with real values once a tenant's case exists — do not fabricate):
Field Value Tenant (not yet onboarded) Paystack merchant KYB status (tenant self-serve — not yet started) Live secret_keyissued(pending tenant KYB) Paybond live-destination gate confirmed fail-closed (pending) Conditions / limits imposed (pending)
-
- Legal/compliance review of Paystack custodial posture, NIP timing, and refund/reversal SLAs —
paystack_commercial_contracts-
Status: Blocked on commercial (external). Not confirmed. Internal production ship blocker (tracked here only — not exposed on the public configure guide). Because Paystack is BYO/self-serve, this is a Paybond-side legal review of the custodial model (funds held on the tenant's Paystack merchant float), NIP settlement timing, and refund/reversal SLAs before Paybond enables any live Paystack destination.
-
Owner / next step: legal/compliance reviews the VA-credit + Transfer (NIP) custodial posture, NIP timing expectations, and refund/reversal SLAs, then records reviewer, date, and sign-off in the tracking table below (not fabricated — leave blank until legal/compliance actually signs off).
-
Tracking table (fill in only with real values once legal/compliance has reviewed — do not fabricate):
Field Value Reviewer (legal/compliance) (pending) Review date (pending) Custodial posture summary accepted (pending) NIP timing / refund-reversal SLA summary accepted (pending) Open conditions / follow-ups (pending) Sign-off status (not started)
-
- Publish tenant onboarding runbook for operator (how to collect
secret_key, set webhook URL) —apps/admin/guides/configure-paystack-settlement.mdx- Status: Complete. Public guide published (BYO self-serve parity with Adyen/Flutterwave: Console write-only
secret_key,X-Paystack-SignatureHMAC-SHA512 verification using the secret key, Gateway webhook URL registration, DVA → fund → Transfer async terminal settlement, ops guidance). The guide is honest about availability: Console Settlement support and thepaybond paystackCLI are forthcoming (rail code deferred behind Flutterwave). Commercial KYB (paystack_commercial_kyb) and contracts (paystack_commercial_contracts) remain internal ops gates tracked in this doc only — not on the public MDX guide.
- Status: Complete. Public guide published (BYO self-serve parity with Adyen/Flutterwave: Console write-only
- SQL / migrations
- Add destination migration
go/gateway/sql/migrations/202607XX_paystack_destination.sql - Add webhook job kind migration
go/gateway/sql/migrations/202607XX_paystack_webhook_job_kind.sql - Add inactive-binding retention migration
go/gateway/sql/migrations/202607XX_paystack_inactive_binding_retention.sql - Add destination secret purge migration
go/gateway/sql/migrations/202607XX_paystack_destination_secret_purge.sql
- Gateway (destination, webhook, purge, settlementconfig, tests)
- Implement webhook ingress & HMAC verify package
go/gateway/internal/paystackwebhook/*(HMAC-SHA512X-Paystack-Signature) - Implement mapping helpers
go/gateway/internal/paystackmap/*(account_number / va_id -> tenant) - Implement destination vault CRUD
go/gateway/internal/db/settlement_paystack_destination_custom.go - Implement secret purge worker
go/gateway/internal/paystacksecretpurge/*and integration test - Add Admin HTTP handlers
go/gateway/internal/httpserver/settlement_paystack_destination.go(upsert, disconnect) - Add public webhook routes
POST /webhooks/{sandbox,live}/paystack - Add webhook integration tests
go/gateway/internal/httpserver/paystack_webhook_integration_test.go(idempotency and max-age) - Wire
settlementconfig.RailPaystackNipand feature-flag
- Harbor / paybond-payments
- Implement tenant client
crates/paybond-payments/src/paystack.rs(transfer/NIP, VA helpers) - Implement Harbor ingest
crates/harbor-intent-escrow/src/paystack_sync.rs - Implement internal endpoints
crates/harbor-intent-escrow/src/internal_paystack.rs - Add idempotency keys and retry/recovery semantics; ensure terminal converge waits for transfer lifecycle webhooks
- Add unit & integration tests: VA credit → fund → transfer success/failure → refund
- Admin console and guides
- Add Admin route
apps/admin/app/api/settlement/paystack/destination/route.ts(+ disconnect) - Add admin guide
apps/admin/guides/configure-paystack-settlement.mdx - Wire console form UX (secret_key write-only, webhook URL copy, currency NGN, label)
- Add admin tests and public-docs boundary test entries
- Kit CLI and cli-parity
- Add Kit CLI command
kit/ts/src/cli/commands/paystack.ts(create/destroy/show webhook) - Update
kit/cli-parity/commands.json&kit/cli-parity/contract.jsonfor Paystack
- Security / hardening / env-var classification
- Enforce fail-closed HMAC verification and timing-safe compare at Gateway (HMAC-SHA512) —
go/gateway/internal/paystackwebhook/hmac.go(VerifyRequestrejects missing/empty signature before comparison;crypto/subtle.ConstantTimeCompareon decoded hex, mirroring Adyen/Flutterwave/Stripe) - Add secret-box encryption & purge safety checks (do not purge while pending jobs exist) —
go/gateway/internal/paystacksecretpurge/worker +ListPaystackDestinationsEligibleForSecretPurge/PurgePaystackDestinationSecretsgate on nopending/processingpaystack_settlementjobs (SEC-020 parity) - Add egress allow-list doc for Paystack hosts and firewall guidance —
docs/security/paystack-hardening.md#egress-allow-list - Add
docs/security/paystack-hardening.mdand env-var classification forPAYSTACK_SECRET_KEY—docs/security/paystack-hardening.md;docs/operations/env-var-security-classification.md§6 - Add threat-model tests for forged webhooks, replay, and late-binding — forged:
TestPaystackWebhookRejectsBadSignature_integration; replay:TestPaystackWebhookLiveEnqueuesIdempotentJob_integration; late-binding:TestPaystackWebhookLateBindingWithinRetentionWindowAccepts_integration/TestPaystackWebhookLateBindingBeyondRetentionWindowRejects_integration(go/gateway/internal/httpserver/paystack_webhook_integration_test.go) -
/attack-chainspass scoped to Paystack — not yet run; tracked as the remaining GA blocker indocs/security/paystack-hardening.md
- Docs / OpenAPI
- Add the Admin guide MDX and Node.js / Go webhook verification snippets in
apps/admin/guides/configure-paystack-settlement.mdx - Update
docs/api/gateway-openapi.yamlwith tenant-scoped Paystack admin endpoints,paystack_admin/paystack_webhookstags, and signed webhook shapes
- Verification exit criteria (sandbox E2E and live gates)
- Sandbox E2E — DVA provisioning (
dedicatedaccount.assign.success) → VA credit webhook (charge.successon thededicated_nubanchannel) → Harbor fund (with read-after-webhookGET /transaction/{id}fund gate) → evidence → live Transfer (NIP, request-accepted only) →transfer.successwebhook →released. Covered end-to-end against a WireMock Paystack fixture (no live secrets) bypaystack_e2e_dva_credit_evidence_transfer_releasesincrates/harbor-intent-escrow/tests/api.rs, alongside the per-segment tests (paystack_charge_success_funds_open_intent_via_internal_events,paystack_va_assign_success_snapshots_account_without_funding,paystack_transfer_success_releases_pending_intent,paystack_live_transfer_stays_pending_until_webhook_releases,paystack_refund_processed_finalizes_pending_refund). - Security exit — Fail-closed HMAC-SHA512 verify, SEC-007 lazy-decrypt inventory, SEC-020 secret purge, and max-age/replay/late-binding guards are shipped and tested (see §7 and
docs/security/paystack-hardening.md). Env-var classifications are documented indocs/operations/env-var-security-classification.md§6 and machine-checked byTestSecurityEnvVars_paystackClassificationCoverageingo/gateway/internal/config/validate_test.go(asserts eachPAYBOND_PAYSTACK_*retention/purge var is classified, non-secret, and categorized). - Commercial exit (live-destination gate + onboarding test) — The live-destination gate fails closed on the paid-plan check (
paystack_paid_plan_required); unit coverage insettlement_paystack_destination_test.go(TestBuildSettlementRailReadiness_paystackPaidPlanGate) and live tenant onboarding integration coverage insettlement_paystack_destination_integration_test.go:TestUpsertPaystackSettlementDestination_liveRequiresPaidPlan(free plan →403, no destination stored) andTestUpsertPaystackSettlementDestination_paidPlanTenantOnboardsLiveDestination(paid self-serve plan connects a live destination, secret persisted encrypted and never echoed). - Commercial exit (external KYB) — tenant self-serve Paystack merchant KYB (
paystack_commercial_kyb) and Paybond-internal legal review (paystack_commercial_contracts) remain external/operational blockers per Implementation TODOs §1. KYB is modeled as an operational control (fail-closed live gate + operator runbook) until akyb_statuscolumn lands, mirroring the Flutterwave/Adyen pattern. No live tenant KYB case has been filed (no fabricated case ids).
Notes:
- The Paystack rail (Gateway, Harbor, Console, CLI) is implemented, and the sandbox-E2E / security / commercial-gate exit criteria above are met with automated tests that run in CI without live secrets. The remaining release gates are the Paystack-scoped
/attack-chainspass and each tenant's external live KYB/commercial readiness (credentials and legal sign-off happen outside the repo).
Honest note
Paystack is comparatively self-serve for merchant onboarding and VA/DVA provisioning relative to aggregator models like TrueLayer: each tenant signs up for their own Paystack merchant account, collects their own sandbox/live secret_key, and completes their own live merchant KYB with Paystack. Paybond does not file a platform-partnership KYB — it is strictly BYO credentials (tenant pastes the secret_key into Console Settlement; the same key verifies X-Paystack-Signature HMAC-SHA512). Commercial KYB and limits still apply for a tenant's live keys, and Paybond keeps a fail-closed live-destination gate plus an internal legal review of custodial posture and refund/reversal SLAs before enabling live tenant destinations. The Paystack gateway, Harbor, Console, and CLI rail code is implemented; the remaining GA blockers are the Paystack-scoped /attack-chains pass and live tenant KYB/commercial gates. TrueLayer / Wise remain deferred until demand and commercial readiness.
Changelog:
- 2026-07-14 — created paystack-settlement-implementation.md
- 2026-07-14 — enriched with Adyen-parity file paths, webhook/test/purge integration test entries, SSRF allow-list, serde deny_unknown_fields guidance, explicit HMAC citation (
X-Paystack-Signature), and admin/kit CLI parity references. - 2026-07-15 — Commercial section 1 documented (self-serve BYO framing): expanded
paystack_commercial_sandbox_prep(self-serve sandbox + DVA/NGN eng checklist, HMAC usessecret_key), reframedpaystack_commercial_kybas tenant self-serve live KYB (external blocker; Paybond does not file platform KYB) with a fail-closed live-destination gate confirmation, keptpaystack_commercial_contractsas an internal legal review, and published the public tenant runbookapps/admin/guides/configure-paystack-settlement.mdx. Commercial KYB/contracts stay in this doc only — not on the public MDX guide. The later implementation added Gateway, Harbor, Console, and CLI support; the remaining release gates are security review and live commercial readiness. - 2026-07-15 — Security / hardening / env-var classification (§7) closed out: added
docs/security/paystack-hardening.md(GA checklist, egress allow-list, secret rotation, log redaction, attack-chain gate), classifiedPAYSTACK_SECRET_KEY/PAYSTACK_API_BASEand the SEC-007/SEC-020-parity retention/purge env vars indocs/operations/env-var-security-classification.md, and added late-binding threat-model integration tests (TestPaystackWebhookLateBindingWithinRetentionWindowAccepts_integration/...BeyondRetentionWindowRejects_integration) to close the forged/replay/late-binding coverage gap. Note: the rest of this document's "rail code deferred" framing is stale — the gateway (paystackwebhook/paystacksecretpurge/paystackmap), Harbor (internal_paystack.rs/paystack_sync.rs), andpaybond-payments::paystackimplementations, admin destination routes, and CLI parity files already exist in-repo (see git status); only the/attack-chainspass and live tenant KYB/commercial gates remain open. - 2026-07-15 — Completed §8 documentation contract: expanded the public tenant guide with raw-body
X-Paystack-SignatureHMAC-SHA512 verification examples (Node.js and Go), corrected availability and CLI guidance, linked the active rail from parent guides and agent-discovery text, and documented Paystack destination/webhook endpoints and shapes in the Gateway OpenAPI underpaystack_admin/paystack_webhooks. - 2026-07-15 — Closed §9 verification exit criteria (sandbox E2E + security + commercial-gate): added the full-path Harbor E2E
paystack_e2e_dva_credit_evidence_transfer_releases(DVA assign → VA credit fund with read-after-webhook GET fund gate → evidence → live Transfer pending → transfer.success release, all against a WireMock fixture), a machine-checked env-var classification coverage testTestSecurityEnvVars_paystackClassificationCoverage, and live tenant onboarding integration tests (TestUpsertPaystackSettlementDestination_liveRequiresPaidPlanfree-plan403and..._paidPlanTenantOnboardsLiveDestinationhappy path). Remaining open gates: Paystack-scoped/attack-chainspass and external tenant live KYB/commercial sign-off.