Put a second key between your payment decision and PayPal.
Wallets, marketplaces and agent platforms call Second Key with the payment they are about to make. It checks the payment against what the person authorized, has PayPal reserve the money, and completes it only when the checks pass or the right person approves.
Sandbox only. Every call reaches the PayPal sandbox; the API refuses any other PayPal host.REST · JSON · Bearer keys · /v1 payments · /v2 AP2
The person confirms an intent. Exactly this payee and amount, or, for an agent, this category, budget and retailers. Second Key freezes it and digests it.
Your app or agent submits the payment against that intent, with an idempotency key. Rules run first; a rule failure is refused before PayPal is called.
PayPal reserves; Second Key decides. Orders v2 with intent AUTHORIZE. Clean: capture. The payer's rule or the evidence: hold for the right person. PayPal's stored order differs from what was approved: void.
Only the executor moves money. A separate worker with no inbound port captures or voids by consuming a single-use approval, and re-reads PayPal's order first. There is no capture endpoint to call.
Quickstart
From an intent to a captured payment
Run Second Key locally against your PayPal sandbox app, then make an agent purchase: the person confirms what the agent may buy, the agent submits a cart, and you read the outcome from Second Key and from PayPal. About ten minutes.
1
Run it locally
Needs Node 24, pnpm 10 and a PayPal sandbox app with Vault enabled. An Anthropic key is optional; without one, every AI check fails closed and payments hold.
Terminal
git clone https://github.com/KingDayveed357/second-key && cd second-key
pnpm install
cp .env.example .env # PayPal sandbox app credentials (Vault enabled)
pnpm db:dev # embedded Postgres; leave it running
pnpm db:migrate && pnpm db:seed # sandbox keys -> .secondkey/dev-keys.json
pnpm api # http://127.0.0.1:4000
pnpm worker # the only process that moves money
The seed creates a sandbox tenant, a payer with a saved PayPal wallet, and one key per role. Export the two you need:
Create the intent with the shopper key (or send { "text": "..." } and Claude drafts the policy for the person to review), show it to them, then confirm. Confirmation freezes it: the agent can read it, never change it.
curl -s $SK/v1/intents \
-H "Authorization: Bearer $SK_SHOPPER_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_text": "Trail-running shoes, size 10, under $90, not white",
"policy": {
"version": 1,
"category": "trail-running shoes",
"retailers": ["rei.com", "backcountry.com"],
"max_total_cents": 9000,
"currency": "USD",
"quantity": 1,
"hard": { "size": "10", "excluded_colors": ["white"] },
"substitution": "same_category_under_max",
"expires_at": "2026-11-17T00:00:00Z"
}
}'
# The person reviews it in your app, then you confirm it:
curl -s -X POST $SK/v1/intents/int_SN6HRr14lVI0xBIV/confirm \
-H "Authorization: Bearer $SK_SHOPPER_KEY" -H "Content-Type: application/json" -d '{}'
With the agent key and an Idempotency-Key. Send product ids only: Second Key fetches the product facts itself and never uses the agent's description. The answer comes back at once with the decision so far.
Poll the purchase until its state is final. Then read what PayPal itself recorded, and re-verify the purchase's audit trail. This run reached CAPTURED about 13 seconds after submission.
# Poll until the state is final (CAPTURED, REJECTED, HELD resolved, VOIDED)
curl -s $SK/v1/purchases/pur_YKv0a0drZieikLZb -H "Authorization: Bearer $SK_AGENT_KEY"
# What PayPal itself holds for it: order, authorization, capture
curl -s $SK/v1/purchases/pur_YKv0a0drZieikLZb/paypal -H "Authorization: Bearer $SK_SHOPPER_KEY"
# Re-verify its hash-chained audit trail (public)
curl -s $SK/v1/verify/purchases/pur_YKv0a0drZieikLZb
Next: change the cart to break the intent (TRL-WHT-10 is the white shoe, TRL-PRO-10 costs $480) and watch it refused before PayPal is called. The sandbox scenarios list every case.
Guide
Protect a transfer with a second key
A person paying someone. The intent names the payee and the amount, and carries the payer's own rule, set in advance: first payments to someone new above an amount get a second look. A held transfer is approved only by the payer's trusted contact, on their own device. The payer and your admins can cancel it but can't approve it.
curl
# 1. The payer confirms exactly this payee and amount (their own rule: a second look
# for first payments of $500 or more)
curl -s $SK/v1/intents -H "Authorization: Bearer $SK_SHOPPER_KEY" -H "Content-Type: application/json" -d '{
"policy": {
"version": 1, "kind": "transfer",
"payee": { "id": "payee_jmorgan", "name": "J. Morgan", "first_payment": true },
"amount_cents": 180000, "currency": "USD", "purpose": "Bail for Danny",
"review_threshold_cents": 50000, "expires_at": "2026-11-17T00:00:00Z"
}
}'
curl -s -X POST $SK/v1/intents/int_ks2C-c8-jVVvz8mv/confirm -H "Authorization: Bearer $SK_SHOPPER_KEY" -H "Content-Type: application/json" -d '{}'
# 2. Your app submits it
curl -s $SK/v1/purchases -H "Authorization: Bearer $SK_AGENT_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: transfer-81c2d7e4" -d '{
"kind": "transfer", "intent_id": "int_ks2C-c8-jVVvz8mv",
"payee_id": "payee_jmorgan", "amount_cents": 180000, "currency": "USD"
}'
Response · the payer's rule paused it
HTTP/1.1 202 Accepted
{
"id": "pur_O82hwTYXSmplmpsy",
"state": "HELD",
"outcome": "HOLD",
"reasons": [
"transfer.review_rule: the payer's rule: a second look for first payments of $500.00 or more to someone new (this is $1,800.00 to J. Morgan, a first payment)"
],
"amount": { "currency_code": "USD", "value": "1800.00" }
}
GET /v1/me/protection
The payer's standing protection: threshold, trusted contact, pending changes.
POST /v1/me/protection/threshold · /guardian
Tightening applies at once. Loosening, or replacing the trusted contact, waits 48 hours and the current contact is told.
POST /v1/me/guardians
Invite a trusted contact; they accept through their own channel.
POST /v1/me/holds/:id/cancel · /notify
The payer cancels a held payment, or reminds the trusted contact.
POST /v1/holds/:id/approve · /deny
The reviewer decides, with a single-use credential sent in the request body, never in a URL. It expires after 30 minutes and locks after 10 wrong attempts.
Guide
Verify AP2 mandates, then settle on PayPal
For platforms whose agents carry AP2 v0.2 mandates. Second Key acts as a delegated verifier, and checks four things:
the SD-JWT delegation chains;
every standard constraint, plus two of its own: attributes such as size and colour, checked against product facts it fetches and signs, and a co-signer above an amount;
a ledger across merchants, so a mandate can't be reused or overspent;
then, before capture, that PayPal's stored order holds exactly the verified checkout.
Every decision comes with an ES256 receipt you can verify against the published key set.
curl
# The agent presents its AP2 chains; you say which issuer and merchant keys you trust.
curl -s $SK/v2/verifications \
-H "Authorization: Bearer $SK_AP2_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: verify-0b7e5a1f" \
-d '{
"checkout": { "mandate": "<open checkout SD-JWT>~~<closed checkout KB-SD-JWT>", "aud": "your-merchant-id", "nonce": "n-7f3a9c21aa" },
"payment": { "mandate": "<open payment SD-JWT>~~<closed payment KB-SD-JWT>", "aud": "your-psp-id", "nonce": "n-2c81d07e55" },
"trust": { "issuers": { "keys": [ { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "kid": "user-device-1" } ] },
"merchants": { "keys": [ { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "kid": "merchant-1" } ] } }
}'
# CONFORMS and settleable? Bind it to the PayPal order you created (within 15 minutes):
curl -s $SK/v2/settlements -H "Authorization: Bearer $SK_AP2_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: settle-0b7e5a1f" -d '{ "verification_id": "ver_...", "psp": "paypal", "order_id": "5O190127TN364715T" }'
Needs a key with the ap2:verify scope. Receipts verify against /.well-known/jwks.json. Interop with Google's reference SDK is tested in both directions; ADR-0023 has the details and limits.
Test
Sandbox scenarios
Named cases you can run against the sandbox, like test cards for card payments. Each one is a real payment through the same API an integrator uses, with an expected outcome. Run one, then compare the result with PayPal's own record and the audit trail. The cart swap is the case spend limits miss: the agent's cart is right, but the order PayPal receives isn't.
Loading counters…
Test · no account, no key
Try AP2 verification in your browser
Run a test verification
No account, no key
Your browser makes a throwaway key and signs two AP2 open mandates with it. A demo agent closes them for the product you pick, Second Key's real verifier checks them and signs receipts, and your browser checks those receipts against our published key set. Nothing is stored and PayPal isn't called.
Make a throwaway key in this browser
Sign two AP2 open mandates with it
Second Key verifies the presented chains
Check every receipt against the published key set
Mandate templates
AP2 v0.2 + Second Key extensions · JSON
Starting points for the constraints inside a user's AP2 mandates (Open Checkout and Open Payment). Fill in the <placeholders>; each says which checks enforce it.
One item, exactly what I meant
A short list of acceptable items, one merchant, a price cap, and the attributes AP2 can't express (size 10, not white).
Any AP2 verifier: line_items, allowed_merchants, amount_range, allowed_payees, reference
Second Key: item_attributes against facts it fetches and signs
Show
{
"$comment": "AP2 v0.2 constraint template from Second Key. Fill in the <placeholders>; the spec is at github.com/google-agentic-commerce/AP2.",
"template": "exactly-what-i-meant",
"title": "One item, exactly what I meant",
"open_checkout": {
"vct": "mandate.checkout.open.1",
"constraints": [
{
"type": "checkout.line_items",
"items": [
{
"id": "item-1",
"acceptable_items": [
{
"id": "<sku>",
"title": "<title>"
},
{
"id": "<another sku>",
"title": "<title>"
}
],
"quantity": 1
}
]
},
{
"type": "checkout.allowed_merchants",
"allowed": [
{
"id": "<merchant id>",
"name": "<merchant name>",
"website": "<https://merchant.example>"
}
]
},
{
"type": "x-secondkey.item_attributes.1",
"rules": [
{
"attribute": "size",
"any_of": [
"10"
]
},
{
"attribute": "color",
"none_of": [
"white"
]
}
]
}
]
},
"open_payment": {
"vct": "mandate.payment.open.1",
"constraints": [
{
"type": "payment.amount_range",
"currency": "USD",
"max": 9000
},
{
"type": "payment.allowed_payees",
"allowed": [
{
"id": "<merchant id>",
"name": "<merchant name>",
"website": "<https://merchant.example>"
}
]
},
{
"type": "payment.reference",
"conditional_transaction_id": "<sd_hash of the Open Checkout Mandate as the agent will present it>"
}
]
},
"notes": [
"Issue acceptable_items as disclosed: a verifier matches only the items it can see.",
"Don't make item_attributes rules selectively disclosable: withholding one would loosen the mandate.",
"amount_range is in minor units (9000 = $90.00)."
]
}
A monthly purchase with a budget
The agent may buy once a month, at most 12 times, never more than $40 a time or $300 in total, across merchants.
Any AP2 verifier: line_items, amount_range, reference
The verifier's ledger: agent_recurrence and budget (Second Key keeps one ledger across merchants)
Show
{
"$comment": "AP2 v0.2 constraint template from Second Key. Fill in the <placeholders>; the spec is at github.com/google-agentic-commerce/AP2.",
"template": "monthly-with-budget",
"title": "A monthly purchase with a budget",
"open_checkout": {
"vct": "mandate.checkout.open.1",
"constraints": [
{
"type": "checkout.line_items",
"items": [
{
"id": "refill",
"acceptable_items": [
{
"id": "<sku>",
"title": "<title>"
}
],
"quantity": 1
}
]
}
]
},
"open_payment": {
"vct": "mandate.payment.open.1",
"constraints": [
{
"type": "payment.agent_recurrence",
"frequency": "MONTHLY",
"max_occurrences": 12
},
{
"type": "payment.amount_range",
"currency": "USD",
"max": 4000
},
{
"type": "payment.budget",
"max": 300,
"currency": "USD"
},
{
"type": "payment.reference",
"conditional_transaction_id": "<sd_hash of the Open Checkout Mandate as the agent will present it>"
}
]
},
"notes": [
"agent_recurrence needs amount_range and budget alongside it.",
"budget.max is in major units (300.00 = $300), unlike amount_range: that is how the spec's example and the reference SDK read it.",
"Months are UTC calendar months: one presentation per month."
]
}
A second signature above a threshold
Up to $500 the agent can pay alone; above it, a co-signer's own key must sign that exact payment.
Any AP2 verifier: amount_range, allowed_payees, reference
Second Key: cosign (COSIGN_REQUIRED until the enrolled key signs)
Show
{
"$comment": "AP2 v0.2 constraint template from Second Key. Fill in the <placeholders>; the spec is at github.com/google-agentic-commerce/AP2.",
"template": "two-person-rule",
"title": "A second signature above a threshold",
"open_checkout": {
"vct": "mandate.checkout.open.1",
"constraints": [
{
"type": "checkout.allowed_merchants",
"allowed": [
{
"id": "<merchant id>",
"name": "<merchant name>",
"website": "<https://merchant.example>"
}
]
},
{
"type": "checkout.line_items",
"items": [
{
"id": "order",
"acceptable_items": [
{
"id": "<sku>",
"title": "<title>"
}
],
"quantity": 1
}
]
}
]
},
"open_payment": {
"vct": "mandate.payment.open.1",
"constraints": [
{
"type": "payment.amount_range",
"currency": "USD",
"max": 250000
},
{
"type": "payment.allowed_payees",
"allowed": [
{
"id": "<merchant id>",
"name": "<merchant name>",
"website": "<https://merchant.example>"
}
]
},
{
"type": "x-secondkey.cosign.1",
"cosigner": {
"name": "<co-signer>",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "<base64url x>",
"y": "<base64url y>"
}
},
"when": {
"amount_over": 50000,
"currency": "USD"
}
},
{
"type": "payment.reference",
"conditional_transaction_id": "<sd_hash of the Open Checkout Mandate as the agent will present it>"
}
]
},
"notes": [
"The co-signer signs the closed payment mandate's reference (returned with COSIGN_REQUIRED) with a JWT of typ secondkey-cosign+jwt.",
"Only the enrolled key counts: the agent's own key, or anyone else's, is refused."
]
}
Mandate templates are starting points for the constraints you put in a user's AP2 mandate: exactly one item, a recurring purchase with a budget, or a second signer above an amount. Each says which checks enforce it: any AP2 verifier, or Second Key's own.
Reference
Keys and roles
Send Authorization: Bearer wk_…. Each key belongs to one tenant and one role, so a key can do only its role's work. Keys are stored only as SHA-256 hashes and can be revoked. Keep them on your server, never in an app or browser.
Role
Held by
Can
Can't
shopper
Your app, for one person
Create, confirm and revoke intents; read their purchases; set their protection; cancel their held payments
Submit a payment; approve a hold that waits for their trusted contact
agent:purchase
The agent or checkout
Read a confirmed intent; submit purchases; read its own purchases
Create or change an intent; see other agents' purchases
ap2:verify
AP2 merchant, PSP or platform
Verify mandates; bind settlements; read both
Anything in /v1
ops
Risk and support teams
Read every purchase, hold and webhook in the tenant; work PayPal disputes
Approve, capture or cancel
admin
The platform
Everything above, plus audited imports
Approve a hold that waits for a trusted contact
Getting keys: this is a hackathon build with no self-serve signup. Run it locally and pnpm db:seed issues sandbox keys. The hosted demo's API is reached only through this site, so you can't send it your own requests. There is no live mode: it is sandbox only by design.
Decisions and states
Outcome
Means
PayPal
Final state
CAPTURE
Everything matches the intent
Authorized, verified, captured
CAPTURED
REJECT
The payment breaks the intent
Never called
REJECTED
HOLD
A rule or the evidence asks for a person
Authorized and reserved
HELD, then CAPTURED or VOIDED (auto-void within 72 h)
VOID
PayPal's stored order isn't what was approved
Authorization voided
VOIDED
Other states you may see on the way: SCREENING, AUTHORIZING, VERIFYING, CAPTURE_APPROVED, CAPTURING, VOIDING, and, rarely, AUTH_FAILED, CAPTURE_DECLINED or NEEDS_RECONCILE (re-read from PayPal automatically). reasons lists each failing or pausing check as check_id: detail.
Errors
Errors are RFC 9457 problem documents with a trace_id to quote when you report one. They never include stack traces or SQL.
Valid JSON that breaks a rule (for example the idempotency key)
429
Slow down; see Retry-After
502
PayPal or the catalog failed; safe to retry with the same key
503
A dependency isn't configured
Idempotency
POST /v1/purchases, /v2/verifications and /v2/settlements require an Idempotency-Key header: 8 to 128 characters of A-Z a-z 0-9 _ . : - for purchases, 8 to 200 printable characters for AP2. Derive it from your own order or cart id, so a retry is the same purchase:
Same key, same body: the original result comes back with 200, and nothing is charged twice.
Same key, different body:422.
Two requests racing with one key: one wins, and the other gets its result or a 409 to retry.
Keys are kept for good, per tenant. Inside Second Key, every PayPal call carries a deterministic request id, so PayPal also deduplicates.
Events
Second Key doesn't send webhooks to integrators yet: poll GET /v1/purchases/:id until the state is final, as in step 4. Holds can take up to 72 hours, so poll those slowly. Second Key itself receives PayPal's webhooks, verifies their signatures with PayPal, and re-reads the resource before acting on any of them. Signed outbound events are on the roadmap.
Rate limits
Routes that call the AI model have per-process budgets: 30 listing screens and 20 analyses a minute. Through this website, each visitor may make 12 such calls a minute. Beyond that you get 429 with Retry-After. The AI also has spend caps; when one is reached, AI checks fail closed and payments hold. Other routes have no rate limit in this build.
Versioning
Versions are in the path: /v1 for payments and /v2 for AP2, which is versioned separately because it follows AP2 v0.2. The OpenAPI contract is version 0.3.0. Every change of behaviour is written up as a decision record (ADR-0001 to 0023). Request bodies are strict: an unknown field is refused, never silently ignored.
What Second Key guarantees, and what it doesn't
Money moves only in the executor, by consuming a single-use approval bound to the exact order, amount, payee and cart. The rule is enforced by import rules and a route inventory test.
AI can only add caution. It can pause a payment, never approve one, and code checks that every quote it cites really appears in the source.
Product facts come from the catalog by id, never from the agent or the merchant.
Every state change is written to a hash-chained audit log in the same transaction. Verify it at GET /v1/verify/purchases/:id.
It protects payments made through platforms that integrate it. It can't stop a payment made elsewhere.
The AI can miss manipulation, so the rules and a person decide.