Skip to content
Developers

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

How an integration works

  1. 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.
  2. 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.
  3. 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.
  4. 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:

Environment
export SK=http://127.0.0.1:4000
export SK_SHOPPER_KEY=wk_...   # "shopper" in .secondkey/dev-keys.json
export SK_AGENT_KEY=wk_...     # "agent"
2

The person confirms what the agent may buy

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 '{}'
Response · 200 (confirmed)
{
  "id": "int_SN6HRr14lVI0xBIV",
  "version": 1,
  "status": "CONFIRMED",
  "policy": { "category": "trail-running shoes", "max_total_cents": 9000, "...": "..." },
  "complete": true,
  "policy_digest": "sha256:1b937792ef8378b3ce7518ae5e24b9fe60d414e498f4962947ad57f6d8cd2a6c",
  "ambiguities": [],
  "confirmed_at": "2026-11-10T03:49:05.737Z"
}
3

The agent submits its cart

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.

curl -s $SK/v1/purchases \
  -H "Authorization: Bearer $SK_AGENT_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-7f3a9c21" \
  -d '{
  "intent_id": "int_SN6HRr14lVI0xBIV",
  "retailer": "rei.com",
  "lines": [{ "product_id": "TRL-GRY-10", "quantity": 1 }]
}'
Response
HTTP/1.1 202 Accepted
{
  "id": "pur_YKv0a0drZieikLZb",
  "state": "CAPTURE_APPROVED",
  "outcome": "CAPTURE",
  "reasons": [],
  "intent_id": "int_SN6HRr14lVI0xBIV",
  "retailer": "rei.com",
  "amount": { "currency_code": "USD", "value": "84.00" }
}
4

Read the outcome, from Second Key and from PayPal

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
GET /v1/purchases/:id/paypal · 200
{
  "order": {
    "id": "4D855638VP030945N",
    "status": "COMPLETED",
    "payee": "7L6D422CPFA9Q",
    "items": [{ "sku": "TRL-GRY-10", "quantity": "1", "unit_amount": { "currency_code": "USD", "value": "84.00" } }]
  },
  "authorizations": [{ "id": "69J2628948039703G", "status": "CAPTURED" }],
  "captures": [{ "id": "02M58451ST820323C", "status": "COMPLETED", "amount": { "currency_code": "USD", "value": "84.00" } }],
  "debug_id": "f2558692072d0"
}

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" }'
Response (abridged)
{
  "verification_id": "ver_TLUJI09l0lQSbwxg",
  "decision": "CONFORMS",              // or NONCONFORMING, CANNOT_CONFIRM, COSIGN_REQUIRED
  "findings": [],
  "constraints": [
    { "type": "checkout.line_items", "result": "pass" },
    { "type": "payment.amount_range", "result": "pass" },
    { "type": "x-secondkey.item_attributes.1", "result": "pass" }
  ],
  "settleable": true,
  "facts_attestation": "eyJhbGciOiJFUzI1NiIs...",
  "ledger": [{ "open_mandate": "…", "kind": "checkout", "uses_before": 0 }],
  "receipts": { "checkout": "eyJ…", "payment": "eyJ…", "kid": "<receipt key id>", "jwks_uri": "/.well-known/jwks.json" }
}

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.

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.

RoleHeld byCanCan't
shopperYour app, for one personCreate, confirm and revoke intents; read their purchases; set their protection; cancel their held paymentsSubmit a payment; approve a hold that waits for their trusted contact
agent:purchaseThe agent or checkoutRead a confirmed intent; submit purchases; read its own purchasesCreate or change an intent; see other agents' purchases
ap2:verifyAP2 merchant, PSP or platformVerify mandates; bind settlements; read bothAnything in /v1
opsRisk and support teamsRead every purchase, hold and webhook in the tenant; work PayPal disputesApprove, capture or cancel
adminThe platformEverything above, plus audited importsApprove 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

OutcomeMeansPayPalFinal state
CAPTUREEverything matches the intentAuthorized, verified, capturedCAPTURED
REJECTThe payment breaks the intentNever calledREJECTED
HOLDA rule or the evidence asks for a personAuthorized and reservedHELD, then CAPTURED or VOIDED (auto-void within 72 h)
VOIDPayPal's stored order isn't what was approvedAuthorization voidedVOIDED

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.

Example
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "about:blank",
  "title": "Idempotency-Key header required",
  "status": 422,
  "trace_id": "req_yp5yc1rd2t"
}
400Malformed body, unknown field or wrong type
401Missing or invalid key
403The key's role can't do this
404Not found in your tenant
409Conflict: wrong state, or a concurrent request
413Body too large
422Valid JSON that breaks a rule (for example the idempotency key)
429Slow down; see Retry-After
502PayPal or the catalog failed; safe to retry with the same key
503A 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.
  • Sandbox only: there are no live keys and no SLA.
  • The full list is in the threat model.

Resources