Wrap an AP2 Mandate with a PQSafe Envelope
Scenario: You want to use PQSafe to authorize recurring bank direct debit payments (SEPA in EU, BACS in UK) via AP2 mandates. This recipe documents the current stub implementation, how to test it in sandbox mode, and what’s coming in Sprint 4.
What are AP2 mandates?
AP2 (Account Payment Protocol v2) is an open banking standard for bank direct debit mandates:
- SEPA Direct Debit (EU): Authorize a creditor to pull funds from a debtor’s bank account in EUR. Used across 36 European countries.
- BACS Direct Debit (UK): Same concept for GBP, used by most UK utilities, SaaS companies, and payroll.
A mandate is a pre-authorization: once signed, the creditor can pull funds periodically without re-authorization. AP2 mandates include a unique mandateId, creditor identifier, debtor IBAN, and the debit scheme.
How PQSafe wraps AP2
PQSafe adds a post-quantum ML-DSA-65 authorization layer on top of the AP2 mandate:
AI Agent → PQSafe Envelope (ML-DSA-65 signed) → AP2 Adapter → Mandate Reference → BankThe envelope enforces:
- Which mandate IDs are authorized (
allowedRecipients= mandate creditor IDs) - Maximum debit amount per invocation
- Time window for the mandate to be exercised
- Approval gate for debits above threshold
Current stub implementation
-
Import the AP2 adapter
import { AP2 } from '@pqsafe/agent-pay'import type { AP2 as AP2Types } from '@pqsafe/agent-pay' -
Define the mandate reference
const mandate: AP2Types.MandateReference = {mandateId: 'MANDATE-2026-001',creditorId: 'DE98ZZZ09999999999', // SEPA creditor identifiercreditorName: 'Acme SaaS GmbH',debtorIBAN: 'GB29NWBK60161331926819',debtorBIC: 'NWBKGB2L',scheme: 'SEPA_CORE', // or 'SEPA_B2B' or 'BACS'currency: 'EUR',maxAmountPerDebit: 500,} -
Create a PQSafe envelope scoped to this mandate
import {generateKeyPair,createSpendEnvelope,createSignedEnvelope,} from '@pqsafe/agent-pay'const { secretKey } = await generateKeyPair()const envelope = createSpendEnvelope({agentId: 'ap2-mandate-agent',maxAmount: 500,currency: 'EUR',allowedRails: ['ap2'], // AP2 rail — stub in current versionallowedRecipients: [mandate.creditorId, // Only this creditor can pull funds],validUntil: new Date(Date.now() + 30 * 86_400_000), // 30-day mandate windowrequireApproval: true,approvalThreshold: 100, // Telegram approval for debits > €100memo: `AP2 mandate — ${mandate.mandateId}`,})const signedEnvelope = createSignedEnvelope(envelope, secretKey) -
Wrap the mandate with the signed envelope (sandbox)
// AP2_SANDBOX_MODE=true in .envconst wrapped = await AP2.wrapMandate(signedEnvelope, mandate, {sandbox: process.env.AP2_SANDBOX_MODE === 'true',})console.log('Mandate wrapped:', wrapped.mandateId)console.log('Status:', wrapped.status) // 'sandbox_accepted'console.log('Reference:', wrapped.mandateReference)console.log('Envelope bound:', wrapped.envelopeId) -
Testing with
AP2_SANDBOX_MODE=trueIn sandbox mode, the AP2 adapter simulates mandate acceptance without connecting to a real bank. All responses are deterministic and safe to test against.
.env // AP2_SANDBOX_MODE=true// Test debit execution (sandbox only)const debitResult = await AP2.executeDebit(signedEnvelope, wrapped.mandateReference, {amount: 50,currency: 'EUR',reference: 'Invoice INV-2026-001',sandbox: true,})console.log('Debit status:', debitResult.status) // 'sandbox_settled'console.log('Simulated TX:', debitResult.simulatedTxId)console.log('Latency (simulated):', debitResult.settlementMs) // 0ms in sandbox
What’s wired vs. TODO
| Feature | Status | Sprint |
|---|---|---|
Mandate object schema (MandateReference) | Wired | Sprint 2 |
Envelope wrapping (wrapMandate) | Wired | Sprint 2 |
| Sandbox simulation | Wired | Sprint 2 |
| SEPA Core dispatch (Airwallex Open Banking API) | TODO | Sprint 4 |
| SEPA B2B dispatch | TODO | Sprint 4 |
| BACS Direct Debit (Modulr) | TODO | Sprint 4 |
| Mandate registration webhook | TODO | Sprint 4 |
| Mandate cancellation lifecycle | TODO | Sprint 4 |
| Recurring debit scheduling | TODO | Sprint 5 |
Expected Sprint 4 full implementation
Sprint 4 will complete the AP2 adapter using:
- SEPA: Airwallex’s Open Banking API (mandates → SEPA transfers)
- BACS: Modulr’s Direct Debit API (UK)
- Webhooks: Mandate registration confirmation, debit execution events, failure notifications
The public interface (wrapMandate, executeDebit) will stay stable — only the sandbox flag behavior changes.
Full sandbox example
import { generateKeyPair, createSpendEnvelope, createSignedEnvelope, AP2,} from '@pqsafe/agent-pay'
// Mandate to wrapconst mandate = { mandateId: 'MANDATE-2026-001', creditorId: 'DE98ZZZ09999999999', creditorName: 'Acme SaaS GmbH', debtorIBAN: 'GB29NWBK60161331926819', debtorBIC: 'NWBKGB2L', scheme: 'SEPA_CORE' as const, currency: 'EUR', maxAmountPerDebit: 500,}
// Signed envelopeconst { secretKey } = await generateKeyPair()const signedEnvelope = createSignedEnvelope( createSpendEnvelope({ agentId: 'ap2-demo', maxAmount: 500, currency: 'EUR', allowedRails: ['ap2'], allowedRecipients: [mandate.creditorId], validUntil: new Date(Date.now() + 30 * 86_400_000), }), secretKey)
// Wrap (sandbox)const wrapped = await AP2.wrapMandate(signedEnvelope, mandate, { sandbox: true })console.log('Status:', wrapped.status) // sandbox_accepted
// Simulate debitconst debit = await AP2.executeDebit(signedEnvelope, wrapped.mandateReference, { amount: 50, currency: 'EUR', reference: 'INV-2026-001', sandbox: true,})console.log('Debit:', debit.status) // sandbox_settledExpected output
Mandate wrapped: MANDATE-2026-001Status: sandbox_acceptedReference: ap2_ref_sandbox_abc123Envelope bound: env_01J4X...Debit status: sandbox_settledSimulated TX: ap2_sandbox_tx_999Next steps
- AP2 Adapter reference
- Telegram Approval Gate — add human approval for SEPA debits above threshold
- Pay USDC Vendor — for crypto payments while AP2 is in sandbox
- Sprint 4 tracker: github.com/PQSafe/pqsafe/milestone/4