PayPal Agent Mandate Extension
An open convention that ties each PayPal order, capture, refund and payout to the mandate a user signed. Version 1.0.0.
Version 1.0.0, 2026-11-12. This is an open convention: anyone may implement it, and it needs no change on PayPal's side. The reference implementation is packages/paypal/src/ids.ts, and Alibi verification checks it in packages/alibi/src/verify/checks/paypal.ts.
When an AI agent pays through PayPal, the payment carries no sign of who authorized it or under what limits, and a later dispute carries none either. This extension writes short, canonical identifiers into fields PayPal already has, so that each order, capture, refund and payout names the mandate the user signed and the public record of the purchase. The identifiers are the same ones the specification defines.
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC 2119] [RFC 8174] when, and only when, they appear in all capitals, as shown here.
Fields used
| PayPal object | Field | PayPal limit | What it carries |
|---|---|---|---|
Order (Orders v2), purchase_units[] | custom_id | 255 characters | ks-custom: the mandate or the purchase, and a digest binding it |
Order, purchase_units[] | reference_id | The mandate id, or the purchase's request id | |
Order, purchase_units[] | description | A human-readable line naming the mandate, and for a single purchase the verifier link | |
| Capture (Payments v2) | invoice_id | 127 characters, unique per merchant | ks-invoice: the purchase, its sequence on the hold and a digest |
| Refund (Payments v2) | custom_id | 127 characters | ks-verdict: the verdict that ordered it |
| Payout item (Payouts v1) | sender_item_id | 63 characters | ks-verdict |
| Every create call | PayPal-Request-Id header | A value derived from the signed object, so a retry is the same operation |
A capture has no custom_id, so each purchase is named by its capture's invoice_id. PayPal's Disputes API returns the order's custom_id and the capture's invoice_id with the disputed transaction (disputed_transactions[].custom and invoice_number), so a dispute leads back to the purchase.
Grammar
The grammar uses ABNF (RFC 5234) with case-sensitive strings (RFC 7405).
; Order: purchase_units[].custom_id
ks-custom = %s"ks1." env kind "." ulid "." digest32 ; 77 characters
env = %s"s" / %s"p" ; sandbox / production
kind = %s"m" / %s"a" ; m: a mandate's hold, a: one purchase's hold
; Capture: invoice_id
ks-invoice = %s"KS1-" ulid "-" seq "-" digest16 ; 60 characters
seq = "0" %x31-39 / %x31-39 DIGIT ; 01 to 99
; Refund: custom_id; payout item: sender_item_id
ks-verdict = %s"ksv1." ulid "." digest12 ; 48 characters
ulid = %x30-37 25CROCKFORD ; 128 bits
digest16 = %x30-37 25CROCKFORD ; 16 bytes, big-endian
digest32 = 43B64URL ; 32 bytes, unpadded
digest12 = 16B64URL ; 12 bytes, unpadded
CROCKFORD = DIGIT / %x41-48 / %x4A-4B / %x4D-4E / %x50-54 / %x56-5A ; 0-9 A-Z without I L O U
B64URL = ALPHA / DIGIT / "-" / "_"Encodings are canonical. Crockford base32 MUST be upper case. Base64url (RFC 4648, section 5) MUST NOT be padded, and the unused low bits of its last character MUST be zero. A parser MUST reject any other spelling, so each digest has exactly one. A value that does not match the grammar is not under this extension.
Digests
H(dst, v) is the domain-separated hash SHA-256(utf8(dst) ‖ 0x00 ‖ utf8(JCS(v))), with JCS from RFC 8785. Inside v, a hash is a 0x-prefixed lower-case hex string, amount is PayPal's decimal string for the amount ("1.00"), and currency is its ISO 4217 code.
| Form | ulid | Digest |
|---|---|---|
ks1.<env>m. | The mandate id (mid) | The mandate hash M = SHA-256(utf8("keelson/mandate-challenge/v1") ‖ 0x00 ‖ H_sd ‖ C_m), the challenge the user's passkey signed. H_sd = H("keelson/mandate/v1", payload_sd) and C_m is the mandate commitment as 32 bytes big-endian. |
ks1.<env>a. | The purchase's request id | H("keelson/alibi/v1", {mandate_hash, cart_hash, merchant_id, amount, currency}), where merchant_id is the Keelson merchant id the mandate allows |
KS1- | The Alibi id | The first 16 bytes of H("keelson/alibi/v1", {mandate_hash, cart_hash, policy_set_hash, countersign_digest, merchant_id, amount, currency, paypal_order_id}), where merchant_id is the PayPal merchant id of the payee and paypal_order_id the order the hold belongs to |
ksv1. | The verdict id | The first 12 bytes of the verdict's EIP-712 digest |
The sequence in KS1- counts the captures taken from one hold. An issuer MUST take it once per purchase, starting at 01, and MUST NOT issue more than 99 captures from one hold. A parser MUST reject 00.
PayPal-Request-Id is the first 36 hex characters of a digest: the countersign digest for a capture, the verdict digest for a refund or a payout, M for a mandate's hold, and SHA-256(utf8(custom_id)) for a single purchase's hold.
Verifying a payment
Given a PayPal order, capture or dispute, and the purchase's Alibi:
- The verifier MUST parse
custom_idandinvoice_idwith the grammar, and MUST treat a value that does not parse as outside this extension. - The
envofcustom_idMUST besfor an Alibi anchored on Arbitrum Sepolia (chain 421614) andpfor one anchored on Arbitrum One (chain 42161). - For
kindm, the digest MUST equal the Alibi's/tee/countersign/mandateHash, and that hash MUST be the challenge of/mandate/payload_sdand/tee/countersign/mandateCommitment. Forkinda, theulidMUST be the Alibi id and the digest MUST equal theks1.<env>a.digest of the countersigned fields. - A capture MUST have an
invoice_id. ItsulidMUST be the Alibi id, and its digest MUST equal the first 16 bytes of theKS1-digest of the countersign,/tee/digest,/paypal/merchant_idand/paypal/order_id. - The verifier SHOULD fetch the public record at
https://verify.keelson.finance/a/<ulid>and check its leaf in the log atlog.keelson.finance. A match binds the PayPal object to the mandate the user signed and to the cart the TEE legs countersigned.
Test vectors
The vectors file is packages/paypal/test/vectors/ks1-v1.json. Its valid entries give each value with its parsed fields, and an implementation MUST parse each to those fields and encode the fields back to the same value. Its invalid entries cover lower-case Crockford, the letters I, L, O and U, = padding, non-zero trailing bits, a leading 8, wrong lengths, an unknown env or kind, and other versions, and an implementation MUST reject every one.
The custom_id and invoice_id vectors were written by PayPal's sandbox on one hold: the order 8MY937060S1606416 and its captures 5VK089183A260134T and 8E547963J1687504T. The verdict reference was generated with encodeVerdictRef.
| Field | Value | Source |
|---|---|---|
custom_id | ks1.sm.01M6ZWXSB31RY6D753ET0RGPZF.JeYgDHlgnVwNJEpAhS0JFmn8D_0Rzg3Zw2Va0K2LXK4 | The mandate hold of both Alibis |
invoice_id | KS1-01M6ZWZJHWRRHQEYNJAT7NMSV8-01-1H6DNY2FEJ24A4TXAY209K6SXS | Alibi 01M6ZWZJHWRRHQEYNJAT7NMSV8, first capture |
invoice_id | KS1-01M6ZX07MFWDDDW4199FDJHWEG-02-1XENCQNVGYZTY0H0E8K2HHSY8W | Alibi 01M6ZX07MFWDDDW4199FDJHWEG, second capture |
custom_id of a refund | ksv1.01M6ZX3QW8T2H5N7K9P1R4S6V8.ZxJoTl1yXFfDHgnl | Generated with encodeVerdictRef |
The digest of the custom_id above is the mandate hash 0x25e6200c79609d5c0d244a40852d091669fc0ffd11ce0dd9c3655ad0ad8b5cae, and both Alibis are in packages/alibi/test/fixtures, so the digests can be recomputed from their fields.
Security considerations
Replay and retries. Every create call carries a PayPal-Request-Id derived from the object it executes, so a retried capture, refund or payout is the same request to PayPal and is not executed twice. Because invoice_id is unique per merchant, PayPal also refuses a second capture under the same KS1- value.
Binding. Each digest covers the signed object it names: a KS1- value cannot be moved to another order, merchant, amount or countersign without its digest failing to verify. A value that parses but whose digest does not match MUST be treated as unverified.
No personal data. invoice_id is shown to the payer, and every field here is visible to PayPal and the merchant. Each form holds only ULIDs and hashes, never a name, an address, an email or the cart's contents.
The unsigned capture id. PayPal assigns capture_id after the countersign, so no signature covers it. A verifier MUST show it as unverified metadata and MUST locate the capture by its invoice_id.
Privacy considerations
The values reveal that a payment was made under an agent mandate, and which mandate, to anyone who sees the PayPal object. A ULID encodes its creation time to the millisecond. Purchases captured from one mandate's hold share its custom_id, so they can be linked to each other; a per-purchase hold (kind a) gives each purchase its own value. The digests reveal nothing about their inputs: each covers a salted or signed value (the mandate's selective-disclosure digests, the countersign digest), so they cannot be reversed by guessing a cart or an amount.
Proposal for PayPal's Agent Toolkit
PayPal's Agent Toolkit create_order tool does not let an agent set intent or custom_id, so an agent using it can neither place a hold nor tag an order. Adding both, with the limits of the Orders v2 API, is enough for any agent to follow this extension.
Changelog
1.0.0 — 2026-11-12: first published version.