Skip to content

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 → Bank

The 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

  1. Import the AP2 adapter

    import { AP2 } from '@pqsafe/agent-pay'
    import type { AP2 as AP2Types } from '@pqsafe/agent-pay'
  2. Define the mandate reference

    const mandate: AP2Types.MandateReference = {
    mandateId: 'MANDATE-2026-001',
    creditorId: 'DE98ZZZ09999999999', // SEPA creditor identifier
    creditorName: 'Acme SaaS GmbH',
    debtorIBAN: 'GB29NWBK60161331926819',
    debtorBIC: 'NWBKGB2L',
    scheme: 'SEPA_CORE', // or 'SEPA_B2B' or 'BACS'
    currency: 'EUR',
    maxAmountPerDebit: 500,
    }
  3. 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 version
    allowedRecipients: [
    mandate.creditorId, // Only this creditor can pull funds
    ],
    validUntil: new Date(Date.now() + 30 * 86_400_000), // 30-day mandate window
    requireApproval: true,
    approvalThreshold: 100, // Telegram approval for debits > €100
    memo: `AP2 mandate — ${mandate.mandateId}`,
    })
    const signedEnvelope = createSignedEnvelope(envelope, secretKey)
  4. Wrap the mandate with the signed envelope (sandbox)

    // AP2_SANDBOX_MODE=true in .env
    const 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)
  5. Testing with AP2_SANDBOX_MODE=true

    In 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

FeatureStatusSprint
Mandate object schema (MandateReference)WiredSprint 2
Envelope wrapping (wrapMandate)WiredSprint 2
Sandbox simulationWiredSprint 2
SEPA Core dispatch (Airwallex Open Banking API)TODOSprint 4
SEPA B2B dispatchTODOSprint 4
BACS Direct Debit (Modulr)TODOSprint 4
Mandate registration webhookTODOSprint 4
Mandate cancellation lifecycleTODOSprint 4
Recurring debit schedulingTODOSprint 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 wrap
const 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 envelope
const { 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 debit
const debit = await AP2.executeDebit(signedEnvelope, wrapped.mandateReference, {
amount: 50,
currency: 'EUR',
reference: 'INV-2026-001',
sandbox: true,
})
console.log('Debit:', debit.status) // sandbox_settled

Expected output

Mandate wrapped: MANDATE-2026-001
Status: sandbox_accepted
Reference: ap2_ref_sandbox_abc123
Envelope bound: env_01J4X...
Debit status: sandbox_settled
Simulated TX: ap2_sandbox_tx_999

Next steps