paybondpaybond
Sign in

Paystack settlement rail — implementation design

Engineer-ready design for Paybond’s Nigeria NIP corridor using Paystack Dedicated Virtual Accounts (VA) and Transfers (NIP). Control plane, gateway webhooks, Harbor driver, admin, migrations, and security.

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_key from 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_key is stored (encrypted). It is used both for outbound API auth and to verify inbound X-Paystack-Signature HMAC-SHA512 — do not invent a separate webhook secret.

Fund → evidence → release / reverse flow (VA → Transfer)

  1. 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 emit dedicatedaccount.assign.success when assignment completes.
  2. Buyer pays into Dedicated Virtual Account (VA) number / beneficiary — Paystack credits the VA (NUBAN-like number).
  3. Paystack emits webhook for VA credit. The canonical fund gate is charge.success where authorization.channel == "dedicated_nuban". Gateway verifies webhook HMAC (X-Paystack-Signature, HMAC-SHA512 using the tenant's secret_key) and resolves account_number / metadata → tenant binding.
  4. Gateway enqueues paystack_settlement webhook job; Harbor ingests internal event POST /internal/v1/paystack/events containing tenant and original webhook.
  5. Harbor marks intent funded once fund gate (credit confirmed) and records provider ids (paystack_va_id, paystack_transaction_id).
  6. 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, and transfer.reversed. On transfer.success → mark released. On failure/reversal → manual_review / retry per policy.
  7. 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 to refunded only after webhook confirmation.

Notes:

  • Prefer snapshotting beneficiary on intent create and never trust live client-supplied payee bank details during terminal payout.
  • Use Paystack metadata on transaction creation to include paybond_intent_id and paybond_tenant_id for 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
  end

SQL schema and migrations

Suggested migration filenames (mirror Adyen pattern with date prefix):

  • go/gateway/sql/migrations/202607XX_paystack_destination.sql
  • go/gateway/sql/migrations/202607XX_paystack_webhook_job_kind.sql
  • go/gateway/sql/migrations/202607XX_paystack_inactive_binding_retention.sql
  • go/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, process
    • go/gateway/internal/paystacksecretpurge/ — inactive secret purge worker
    • go/gateway/internal/paystackmap/ — account_number/va_id → tenant lookup helpers
    • go/gateway/internal/db/settlement_paystack_destination_custom.go — vault CRUD
    • go/gateway/internal/db/settlement_paystack_inventory_custom.go — inventory helpers
    • go/gateway/internal/httpserver/settlement_paystack_destination.go — Admin HTTP handlers (upsert, disconnect)
    • go/gateway/internal/httpserver/settlement_paystack_destination_test.go — unit/integration tests
    • go/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 test
    • go/gateway/internal/httpserver/paystack_destination_secret_purge_worker.go — purge wiring
    • go/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 read
    • POST /v1/admin/settlement/paystack/destination/disconnect — deactivate
  • Public webhook ingress:

    • POST /webhooks/paystack — compatibility
    • POST /webhooks/live/paystack
    • POST /webhooks/sandbox/paystack

Processing order (ship blockers):

  1. Read raw body; verify HMAC signature using X-Paystack-Signature header (HMAC-SHA512 of raw payload using configured secret). Fail-closed on verification failure.
  2. Resolve destination via webhook token AND the account_number / va_id or metadata.client_reference (both must agree).
  3. Max-age guard: apply a conservative window (e.g., 72h inactive binding window for late deliveries) guarded by retention policy.
  4. Require binding metadata for money-state webhooks; drop unbound events to manual review.
  5. Enqueue one paystack_settlement job 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 refunded only 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-Signature header. 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's secret_key from the vault.
  • Verification steps:
    1. Read raw request body bytes (do not canonicalize or re-encode).
    2. Compute HMAC-SHA512(raw_body, secret_key) and compare in constant time to the X-Paystack-Signature header value (follow Paystack docs for expected encoding).
    3. If mismatch -> return 401 / 4xx and log audit; do not process.
    4. Resolve account binding via data.account_number or metadata.client_reference and webhook_uri_token URL param; both must agree to attribute to tenant.
    5. 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_key is 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_settlement jobs are pending/processing for 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_key is used to compute the X-Paystack-Signature HMAC. Store only the tenant secret_key (encrypted) in the destination vault; webhook verification uses that same secret_key. Retain inactive-binding windows to allow late-arriving webhooks to be attributed during the retention window.

Rail identifier and naming

SurfaceValue
Settlement rail stringpaystack_nip
Rust enumSettlementRail::PaystackNip
Go constsettlementconfig.RailPaystackNip
Webhook job kindpaystack_settlement

Naming follows adyen_* / truelayer_*paystack_* pattern.

Security, threat model, and invariants

  • Tenant isolation invariants (every Harbor mutation):
    1. Authenticated automation context carries tenant (mTLS SAN / forward bearer). Never trust webhook tenant identifiers alone.
  1. Intent load is tenant-scoped; abort on mismatch.
  2. Destination vault decrypt only for that tenant and matching account_number / va_id snapshot.
  3. Payout beneficiary must equal create-time snapshot.
  4. 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::Custom is 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)

  1. Migrations and destination vault and settlementconfig rail (paystack_nip)
  2. Admin console destination upsert and disconnect
  3. Public webhook ingress and HMAC verify and job kind paystack_settlement
  4. Harbor internal ingest and VA credit → fund gate
  5. Evidence-based Transfer (NIP) release driver and pending-terminal reconcile
  6. Secret purge worker and retention rules
  7. 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.go
    • go/gateway/internal/db/settlement_paystack_inventory_custom.go
    • go/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.ts
    • apps/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 -> gateway
    • apps/admin/tests/lib/public-docs-boundary.test.ts — public docs boundary test should include Paystack guide presence
    • kit/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)

  1. 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.
  2. Decision: BYO per-tenant Paystack credentials (Adyen parity) — store the encrypted secret_key in the destination vault; Harbor calls Paystack with tenant credentials. There is no separate webhook secret: the same secret_key verifies the X-Paystack-Signature HMAC-SHA512.
  3. Decision: Use Paystack metadata and VA/account_number binding to attach paybond_intent_id and paybond_tenant_id. Gateway must reject unbound webhooks.
  4. Decision: Webhook verify using HMAC-SHA512 (X-Paystack-Signature) and constant-time compare; fail-closed.
  5. 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_kyb is 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_contracts is a Paybond-internal legal review. Neither commercial gate is exposed on the public configure guide (mirrors the Flutterwave 2026-07-15 public scrub).

Sources

Implementation TODOs

Grouped, actionable checklist to allow engineers to begin implementation later (document kept deferred — use paystack_* prefixes).

  1. 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_key from 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 guide apps/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_key to compute the X-Paystack-Signature HMAC-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.
    • 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.
  • 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_status column 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):

      FieldValue
      Tenant(not yet onboarded)
      Paystack merchant KYB status(tenant self-serve — not yet started)
      Live secret_key issued(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):

      FieldValue
      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-Signature HMAC-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 the paybond paystack CLI 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.
  1. 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
  1. Gateway (destination, webhook, purge, settlementconfig, tests)
  • Implement webhook ingress & HMAC verify package go/gateway/internal/paystackwebhook/* (HMAC-SHA512 X-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.RailPaystackNip and feature-flag
  1. 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
  1. 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
  1. 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.json for Paystack
  1. Security / hardening / env-var classification
  • Enforce fail-closed HMAC verification and timing-safe compare at Gateway (HMAC-SHA512) — go/gateway/internal/paystackwebhook/hmac.go (VerifyRequest rejects missing/empty signature before comparison; crypto/subtle.ConstantTimeCompare on 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 / PurgePaystackDestinationSecrets gate on no pending/processing paystack_settlement jobs (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.md and env-var classification for PAYSTACK_SECRET_KEYdocs/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-chains pass scoped to Paystack — not yet run; tracked as the remaining GA blocker in docs/security/paystack-hardening.md
  1. 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.yaml with tenant-scoped Paystack admin endpoints, paystack_admin / paystack_webhooks tags, and signed webhook shapes
  1. Verification exit criteria (sandbox E2E and live gates)
  • Sandbox E2E — DVA provisioning (dedicatedaccount.assign.success) → VA credit webhook (charge.success on the dedicated_nuban channel) → Harbor fund (with read-after-webhook GET /transaction/{id} fund gate) → evidence → live Transfer (NIP, request-accepted only) → transfer.success webhook → released. Covered end-to-end against a WireMock Paystack fixture (no live secrets) by paystack_e2e_dva_credit_evidence_transfer_releases in crates/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 in docs/operations/env-var-security-classification.md §6 and machine-checked by TestSecurityEnvVars_paystackClassificationCoverage in go/gateway/internal/config/validate_test.go (asserts each PAYBOND_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 in settlement_paystack_destination_test.go (TestBuildSettlementRailReadiness_paystackPaidPlanGate) and live tenant onboarding integration coverage in settlement_paystack_destination_integration_test.go: TestUpsertPaystackSettlementDestination_liveRequiresPaidPlan (free plan → 403, no destination stored) and TestUpsertPaystackSettlementDestination_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 a kyb_status column 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-chains pass 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 uses secret_key), reframed paystack_commercial_kyb as tenant self-serve live KYB (external blocker; Paybond does not file platform KYB) with a fail-closed live-destination gate confirmation, kept paystack_commercial_contracts as an internal legal review, and published the public tenant runbook apps/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), classified PAYSTACK_SECRET_KEY / PAYSTACK_API_BASE and the SEC-007/SEC-020-parity retention/purge env vars in docs/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), and paybond-payments::paystack implementations, admin destination routes, and CLI parity files already exist in-repo (see git status); only the /attack-chains pass 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-Signature HMAC-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 under paystack_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 test TestSecurityEnvVars_paystackClassificationCoverage, and live tenant onboarding integration tests (TestUpsertPaystackSettlementDestination_liveRequiresPaidPlan free-plan 403 and ..._paidPlanTenantOnboardsLiveDestination happy path). Remaining open gates: Paystack-scoped /attack-chains pass and external tenant live KYB/commercial sign-off.