Skip to main content

Runbook — Add a Paddle connection over the wire

This runbook adds a Paddle Billing (sandbox) connection without touching the console — every step is a curl against the gateway's REST API. It exercises the whole enforcement surface in one pass:

  • the OpenAPI import path (/v1/connections/import-openapi) and its automatic risk classification,
  • credential sealing (the key is vaulted server-side, never shown to an agent),
  • the three policy outcomes: allow, requires approval, and deny by default,
  • the approval queue (202 → approve → the held call executes),
  • the connection kill switch and the audit chain.

Paddle is a good test subject: it is not in the preset catalog, its API uses plain Bearer auth (the only scheme the importer emits today), and it has genuinely risky endpoints — cancelling a subscription, refunding a transaction — that make the approval gate mean something.

Why the protocol and not the console

The console's Connect your own flow (custom API / OpenAPI discovery) is still rolling out, so for a non-preset API like Paddle the REST protocol is currently the only path. Once the connection exists it shows up in the console like any other — you can manage its capabilities, decide approvals, and throw the kill switch from Access → Connections.

Prerequisites

  • A local gateway running normally — not --demo or --mock. On a mock gateway every invoke "succeeds" with "mocked": true and never touches Paddle; check for that field before trusting a result.
  • A Paddle sandbox account and jq on your machine.
  • The gateway listens on 127.0.0.1:8787 by default (--addr / PERMAURA_ADDR to change). All calls below assume that address.

Step 1 — create a sandbox API key in Paddle

In the sandbox dashboard at sandbox-vendors.paddle.com: Developer Tools → Authentication → API keys → New API key.

  • Scope it minimally: product.read, price.read, customer.read. Add subscription.write and adjustment.write only if you want the gated actions to actually execute once approved.
  • Sandbox keys start pdl_sdbx_apikey_ and are shown once at creation — copy it immediately. Default expiry is 90 days.
Sandbox keys cannot touch live

Paddle keys are environment-locked: a sandbox key only works against sandbox-api.paddle.com, a live key only against api.paddle.com. The connection you build here is pinned to the sandbox host, so nothing in this runbook can reach live data.

Step 2 — open an operator session

POST /v1/sessions is credential-free but loopback-only: the gateway refuses it when hosted or when the request arrives through the public tunnel. Which identity to name depends on how the store was seeded: a clean boot (PERMAURA_SEED=0, the installer's path) registers operator, while a bare-binary default boot demo-seeds the store and registers codex-cli instead — a 404 on one means try the other.

TOK=$(curl -s -X POST http://127.0.0.1:8787/v1/sessions \
-H 'content-type: application/json' \
-d '{"agent_id":"operator"}' | jq -r .token)
echo "$TOK"

Every call below sends Authorization: Bearer $TOK.

Step 3 — write a trimmed OpenAPI spec

Paddle publishes an official spec (PaddleHQ/paddle-openapi), but at ~7.4 MB it exceeds the gateway's request body limit (~2 MB) — and importing hundreds of endpoints makes a noisy test anyway. A trimmed spec is deterministic and keeps the capability set focused. The importer reads the pinned upstream host from servers[0].url; an agent can never redirect a call off that host.

Save this as paddle-mini.yaml:

openapi: 3.1.0
info:
title: Paddle Billing (trimmed for import test)
version: "1"
servers:
- url: https://sandbox-api.paddle.com
paths:
/products:
get:
operationId: listProducts
summary: List products
parameters:
- name: per_page
in: query
required: false
schema: { type: integer }
/prices:
get:
operationId: listPrices
summary: List prices
parameters:
- name: per_page
in: query
required: false
schema: { type: integer }
/customers:
get:
operationId: listCustomers
summary: List customers
parameters:
- name: per_page
in: query
required: false
schema: { type: integer }
/discounts:
post:
operationId: createDiscount
summary: Create a discount
requestBody:
content:
application/json:
schema:
type: object
required: [type, description, amount]
properties:
type: { type: string }
description: { type: string }
amount: { type: string }
/adjustments:
post:
operationId: createAdjustment
summary: Refund or credit a transaction
requestBody:
content:
application/json:
schema:
type: object
required: [action, transaction_id, reason]
properties:
action: { type: string }
transaction_id: { type: string }
reason: { type: string }
/subscriptions/{subscription_id}/cancel:
post:
operationId: cancelSubscription
summary: Cancel a subscription
parameters:
- name: subscription_id
in: path
required: true
schema: { type: string }
requestBody:
content:
application/json:
schema:
type: object
properties:
effective_from: { type: string }

Step 4 — import it

The import body carries the spec as a string field (spec), not embedded JSON — jq --rawfile handles the wrapping. The id you choose is used verbatim (no slugging or validation on this endpoint), and it becomes three things at once: the connection id, the capability-id prefix, and the secret id (sec_<id>). Pick a lowercase slug.

jq -n --rawfile spec paddle-mini.yaml \
'{id:"paddle-sandbox", name:"Paddle (sandbox)", label:"Paddle", spec:$spec}' \
> import-body.json

curl -s -X POST http://127.0.0.1:8787/v1/connections/import-openapi \
-H "authorization: Bearer $TOK" -H 'content-type: application/json' \
-d @import-body.json | jq

You get 201 with a preview of every derived capability. Expect this classification:

CapabilityActionRiskAuto-gated?
paddle-sandbox.listProductssearchmediumno
paddle-sandbox.listPricessearchmediumno
paddle-sandbox.listCustomerssearchmediumno
paddle-sandbox.createDiscountcreatehighno
paddle-sandbox.createAdjustmentcreatehighno
paddle-sandbox.cancelSubscriptioncreatehighno
The keyword net is not enough on its own

The classifier auto-escalates mutating endpoints whose path or operationId mention refund, charge, capture, payout, or transfer (and every DELETE) to critical + approval required. Paddle's adjustment — which literally refunds money — sails past that net because the word "refund" appears nowhere in its path or operation id. That's the point of this test: automatic classification is a floor, and the policy you write in the next step is where the real gating happens. (A second safety net does catch it: once allowed by a policy, any high- or critical-risk capability is escalated to approval by the engine regardless of the policy's lists.)

The connection is live (and visible in the console) as soon as the 201 lands — but nothing can use it yet: there's no credential, and the policy engine denies by default.

Step 5 — seal the credential

curl -s -X POST http://127.0.0.1:8787/v1/connections/paddle-sandbox/credential \
-H "authorization: Bearer $TOK" -H 'content-type: application/json' \
-d '{"value":"pdl_sdbx_apikey_…your key…"}' | jq

The key is envelope-encrypted into the vault and injected server-side as Authorization: Bearer on each upstream call. It is never returned by any endpoint and never reaches an agent.

Step 6 — policy and grant

Policies key on the action id — the capability id minus its paddle-sandbox. prefix. Two rules shape this policy:

  • An action must be in allow to be usable at all. requires_approval does not grant anything — it escalates an allowed action into a held call. An action on neither list is denied by default.
  • The engine escalates any allowed high or critical capability to approval on its own, so the two money actions below would be gated even without listing them. Listing them anyway documents the intent — and it is the only way to gate a low/medium action.

This policy produces all three outcomes: reads are allowed outright, the two money-adjacent actions are allowed and gated, and createDiscount is deliberately left off every list so it demonstrates deny-by-default.

POLICY=$(curl -s -X POST http://127.0.0.1:8787/v1/policies \
-H "authorization: Bearer $TOK" -H 'content-type: application/json' \
-d '{
"connection_id": "paddle-sandbox",
"name": "Paddle sandbox — reads allowed, money gated",
"allow": ["listProducts", "listPrices", "listCustomers", "cancelSubscription", "createAdjustment"],
"deny": [],
"requires_approval": ["cancelSubscription", "createAdjustment"]
}' | jq -r .policy_id)

curl -s -X POST http://127.0.0.1:8787/v1/grants \
-H "authorization: Bearer $TOK" -H 'content-type: application/json' \
-d '{
"name": "Paddle sandbox test",
"policy_ids": ["'"$POLICY"'"],
"all_agents": true,
"period": "day",
"overall_limit": 50
}' | jq

The grant binds the policy to every approved agent with a 50-calls-per-day ceiling — enough for a test run, small enough that a runaway loop hits the wall fast.

Step 7 — invoke all three outcomes

Allowed read — expect 200, decision: allow, and real sandbox data in result (sensitive keys are redacted before the response reaches the caller):

curl -s -X POST http://127.0.0.1:8787/v1/invoke \
-H "authorization: Bearer $TOK" -H 'content-type: application/json' \
-d '{"capability":"paddle-sandbox.listProducts","params":{"per_page":5},
"reason":"Runbook smoke test: list sandbox products"}' | jq

If result contains "mocked": true, the gateway is running --demo/--mock and never called Paddle — restart it without those flags and re-run.

Approval-gated action — expect 202 with an approval_id; nothing has executed yet. (The hold fires twice over here: the policy lists the action under requires_approval, and the engine escalates any allowed high-risk capability anyway.)

curl -s -X POST http://127.0.0.1:8787/v1/invoke \
-H "authorization: Bearer $TOK" -H 'content-type: application/json' \
-d '{"capability":"paddle-sandbox.cancelSubscription",
"params":{"subscription_id":"sub_01xxxxxxxxxxxxxxxxxxxxxxx","effective_from":"next_billing_period"},
"reason":"Runbook: exercise the approval gate"}' | jq

Deny by defaultcreateDiscount is on no list, so expect 403:

curl -s -X POST http://127.0.0.1:8787/v1/invoke \
-H "authorization: Bearer $TOK" -H 'content-type: application/json' \
-d '{"capability":"paddle-sandbox.createDiscount",
"params":{"type":"percentage","description":"should never exist","amount":"100"},
"reason":"Runbook: prove deny-by-default"}' | jq

Step 8 — decide the held call

List the queue and approve (or deny) the held cancellation. On approve, the gateway re-checks every gate — connection still enabled, agent still approved, grant not expired or over budget — and only then executes the held request server-side:

curl -s "http://127.0.0.1:8787/v1/approvals?status=pending" \
-H "authorization: Bearer $TOK" | jq

curl -s -X POST http://127.0.0.1:8787/v1/approvals \
-H "authorization: Bearer $TOK" -H 'content-type: application/json' \
-d '{"approval_id":"<id from the list>","approve":true}' | jq

With the placeholder subscription_id above, the approved call executes and comes back with Paddle's 404 as its upstream_status — which is exactly what you want to see: the gate worked, the execution happened, and the audit trail recorded both. Use a real sandbox subscription id (and a key with subscription.write) to watch a genuine cancellation go through. This queue is the same one the console's Approvals page renders, so you can decide it there instead.

Step 9 — the kill switch

Pause the whole connection, watch the same allowed read from Step 7 get refused, then re-enable it:

curl -s -X PATCH http://127.0.0.1:8787/v1/connections/paddle-sandbox \
-H "authorization: Bearer $TOK" -H 'content-type: application/json' \
-d '{"disabled":true}' | jq

# … re-run the listProducts invoke → 403, "Connection … is disabled" …

curl -s -X PATCH http://127.0.0.1:8787/v1/connections/paddle-sandbox \
-H "authorization: Bearer $TOK" -H 'content-type: application/json' \
-d '{"disabled":false}' | jq

Step 10 — read the audit chain

Every decision above — the allow, the hold, the approval, the denials — is a hash-chained audit event:

curl -s "http://127.0.0.1:8787/v1/audit-events?limit=20" \
-H "authorization: Bearer $TOK" | jq '.chain_intact, (.events[].payload | {action, decision})'

chain_intact must be true.

Cleanup

curl -s -X DELETE http://127.0.0.1:8787/v1/connections/paddle-sandbox \
-H "authorization: Bearer $TOK" | jq

Deleting the connection cascades: its policies, their grant memberships, its capabilities, and the sealed credential all go with it. Revoke the sandbox API key in the Paddle dashboard if you're done with it.

Troubleshooting

SymptomCause
404 mentioning secret 'sec_paddle-sandbox' on an allowed invokeNo credential sealed yet — Step 5.
403 "Denied by default" on an action you expected to workNo enabled grant holds a policy covering that action — Step 6, and check the action id is the segment after the first dot.
result contains "mocked": trueGateway is running --demo or --mock — it never called Paddle.
413 on importSpec over the ~2 MB request limit — trim it (Step 3).
400 "servers" on importThe spec has no absolute servers[0].url — the importer takes the pinned host from there and has no override field.
401 on any callThe session token isn't valid on this gateway — re-run Step 2. (On the default SQLite store, tokens survive restarts; an in-memory gateway mints a fresh session table every boot.)
421 Misdirected RequestThe gateway only answers loopback host headers (127.0.0.1, localhost, [::1], plus configured extras) — don't call it through another DNS name.
Paddle returns 403 forbidden upstreamThe API key lacks the scope for that endpoint — check the key's permissions in the Paddle dashboard.
Auth schemes beyond Bearer

The importer currently emits Bearer auth for every capability — a perfect match for Paddle. APIs that authenticate with an X-API-Key header, basic auth, or query-string keys need a preset manifest (which can express those schemes) until the import path grows an auth option.