Docs
Get an API key

Issue your first closed mandate

Issue a closed mandate, record consent, verify the intended charge, hand it to settlement, and inspect the evidence.

In short

7 calls, in the order this page makes themFor people and agents
  1. 1POST/v1/mandateIssue a mandate
  2. 2POST/v1/mandate/{mandate_id}/confirmConfirm a mandate
  3. 3POST/v1/mandate/verifyVerify a token against a charge
  4. 4GET/v1/mandate/{mandate_id}Retrieve a mandate
  5. 5GET/v1/mandate/{mandate_id}/evidenceEvidence pack
  6. 6GET/v1/mandateList a user's mandates
  7. 7POST/v1/mandate/{mandate_id}/revokeRevoke a mandate

The same list heads this page's Markdown:/mandate/getting-started-mandates.md

In this tutorial, a user authorizes an agent to book one itinerary from Demo Travel for at most USD 1,800. You will create the proposal, record consent, verify the intended USD 1,700 charge, and inspect the audit trail.

The partner side stops before exercise. Settlement receives the signed token and exact charge, then performs the state-changing exercise when it commits the payment.

A partner issues and confirms a mandate, may verify it, then hands the token and charge to settlement for exercise.

Before you start

Every request in this tutorial goes through the OEX API gateway:

export OEX_API_KEY="<your API key>"
export OEX_BASE_URL="https://api.oex.sh"

Amounts are integer minor units: 180000 USD is USD 1,800.00. The route prefix is /v1/mandate, singular.

1. Propose the authority

Issue creates a draft. It does not create a signed payment authority yet.

Request
curl -X POST "$OEX_BASE_URL/v1/mandate" \
  -H "content-type: application/json" \
  -H "x-api-key: $OEX_API_KEY" \
  -d '{
    "user_id": "usr_demo_123",
    "mode": "closed",
    "payee": {
      "id": "merchant_demo",
      "name": "Demo Travel"
    },
    "payment_amount": {
      "currency": "USD",
      "amount": 180000
    },
    "payment_instrument": {
      "id": "pi_demo_visa",
      "type": "card",
      "description": "Demo Visa ending 4242"
    },
    "agent_id": "agent_demo",
    "expires_in_seconds": 3600,
    "constraints": [
      {
        "type": "payment.amount_range",
        "currency": "USD",
        "min": 100000,
        "max": 180000
      },
      {
        "type": "payment.allowed_payees",
        "allowed": [{ "id": "merchant_demo" }]
      }
    ],
    "evidence": {
      "source": "llm_agent",
      "presented": {
        "route": "SIN-NRT",
        "departure_date": "2026-09-10",
        "merchant": "Demo Travel",
        "maximum": "USD 1,800.00"
      },
      "interaction": [
        "Book this itinerary with Demo Travel if it stays under USD 1,800."
      ],
      "context": "The user reviewed the merchant, itinerary and maximum total.",
      "confidence_score": 0.98
    }
  }'

The response is a pending proposal:

Response
{
  "id": "mdt_0123456789abcdef0123456789abcdef",
  "status": "pending",
  "claims_preview": {
    "iss": "https://mandates.oex.dev",
    "sub": "usr_demo_123",
    "jti": "mdt_0123456789abcdef0123456789abcdef",
    "vct": "mandate.payment.1",
    "mode": "closed",
    "payee": { "id": "merchant_demo", "name": "Demo Travel" },
    "payment_amount": { "currency": "USD", "amount": 180000 },
    "payment_instrument": { "id": "pi_demo_visa", "type": "card", "description": "Demo Visa ending 4242" },
    "constraints": [
      { "type": "payment.amount_range", "currency": "USD", "min": 100000, "max": 180000 },
      { "type": "payment.allowed_payees", "allowed": [{ "id": "merchant_demo" }] }
    ],
    "agent_id": "agent_demo"
  },
  "confirm_expires_at": "2026-08-23T09:15:00+00:00"
}

Save id and confirm_expires_at. The preview has no iat, exp, consent_method, or token because the user has not confirmed it.

evidence is the consent trail and is stored for audit only; none of it is evaluated. presented is what the user saw, interaction the messages that led to consent, and confidence_score (0 to 1) how sure the gathering agent is that the user meant it.

A 422 means the proposal was invalid and nothing was created. Fix the field named in detail rather than retrying the same body.

2. Present the terms and confirm

Show the user the merchant, maximum amount, instrument, expiry, and constraints. Confirm only after the user accepts those exact terms.

The optional confirmation object is stored in the event history. It does not enter the JWT.

export MANDATE_ID="mdt_0123456789abcdef0123456789abcdef"

curl -X POST "$OEX_BASE_URL/v1/mandate/$MANDATE_ID/confirm" \
  -H "content-type: application/json" \
  -H "x-api-key: $OEX_API_KEY" \
  -d '{
    "confirmation": {
      "channel": "sms",
      "message_id": "msg_demo_42",
      "confirmed_by": "usr_demo_123"
    }
  }'
{
  "confirmed": true,
  "id": "mdt_0123456789abcdef0123456789abcdef",
  "token": "<mandate-jwt>",
  "claims": {
    "iss": "https://mandates.oex.dev",
    "sub": "usr_demo_123",
    "jti": "mdt_0123456789abcdef0123456789abcdef",
    "vct": "mandate.payment.1",
    "mode": "closed",
    "payee": { "id": "merchant_demo", "name": "Demo Travel" },
    "payment_amount": { "currency": "USD", "amount": 180000 },
    "payment_instrument": { "id": "pi_demo_visa", "type": "card", "description": "Demo Visa ending 4242" },
    "constraints": [
      { "type": "payment.amount_range", "currency": "USD", "min": 100000, "max": 180000 },
      { "type": "payment.allowed_payees", "allowed": [{ "id": "merchant_demo" }] }
    ],
    "agent_id": "agent_demo",
    "consent_method": "partner_attested",
    "iat": 1787475600,
    "exp": 1787479200
  },
  "status": "active"
}

Require confirmed: true and retain token exactly as returned.

If the proposal was already confirmed, revoked, or allowed to pass its confirmation window, the endpoint still returns HTTP 200 but sets confirmed: false, includes a reason, and returns no token. Branch on confirmed, never on the HTTP status.

3. Verify the exact intended charge

The itinerary now costs USD 1,700. Verify that exact amount and merchant before handing the payment to settlement.

Request
curl -X POST "$OEX_BASE_URL/v1/mandate/verify" \
  -H "content-type: application/json" \
  -H "x-api-key: $OEX_API_KEY" \
  -d '{
    "token": "<mandate-jwt>",
    "charge": {
      "amount": 170000,
      "currency": "USD",
      "payee_id": "merchant_demo",
      "at": "2026-08-23T09:30:00+00:00"
    }
  }'
Response
{
  "valid": true,
  "mandate_id": "mdt_0123456789abcdef0123456789abcdef",
  "checks": [
    { "check": "signature", "ok": true, "reason": "valid ES256 signature" },
    { "check": "expiry", "ok": true, "reason": "not expired" },
    { "check": "known", "ok": true, "reason": "issued by this service" },
    { "check": "confirmed", "ok": true, "reason": "user confirmed" },
    { "check": "revocation", "ok": true, "reason": "not revoked" },
    { "check": "single_use", "ok": true, "reason": "not yet exercised" },
    { "check": "agent_binding", "ok": true, "reason": "caller matches mandate agent" },
    { "check": "payment.amount_range", "ok": true, "reason": "within amount range" },
    { "check": "payment.allowed_payees", "ok": true, "reason": "payee allowed" }
  ]
}

valid is true only when every check has ok: true. Verification is advisory and repeatable; it records a verified event but does not reserve budget, charge the instrument, or exercise the mandate.

Do not omit charge for a payment decision. Without it, the service can validate the token and lifecycle but cannot evaluate constraints, currency, or amount.

4. Hand off to settlement

Send settlement:

  • the mandate ID,
  • the unchanged JWT,
  • the same charge you verified, and
  • your reconciliation reference.

The partner API does not need to perform exercise. Keeping the state-changing operation with settlement prevents the agent that requested authority from declaring its own payment final.

Settlement exercises the mandate as part of committing the charge:

Request
curl -X POST "$OEX_BASE_URL/v1/mandate/$MANDATE_ID/exercise" \
  -H "content-type: application/json" \
  -H "x-api-key: $OEX_API_KEY" \
  -d '{
    "token": "<mandate-jwt>",
    "charge": {
      "amount": 170000,
      "currency": "USD",
      "payee_id": "merchant_demo"
    },
    "reference_id": "pay_demo_001"
  }'
Response
{
  "exercised": true,
  "mandate_id": "mdt_0123456789abcdef0123456789abcdef",
  "status": "exercised",
  "checks": [
    { "check": "signature", "ok": true, "reason": "valid ES256 signature" },
    { "check": "single_use", "ok": true, "reason": "not yet exercised" },
    { "check": "jti_match", "ok": true, "reason": "token jti matches path" }
  ]
}

checks is abridged here; exercise runs the same checks as verify, then jti_match. Require exercised: true. A second exercise of a closed mandate fails single_use. reference_id is your reconciliation reference; it is stored, not used for deduplication.

5. Read the result and evidence

Retrieve the mandate after settlement:

Request
curl "$OEX_BASE_URL/v1/mandate/$MANDATE_ID" \
  -H "x-api-key: $OEX_API_KEY"

Its computed status is now exercised after a successful settlement. To inspect the consent trail and event history:

Request
curl "$OEX_BASE_URL/v1/mandate/$MANDATE_ID/evidence" \
  -H "x-api-key: $OEX_API_KEY"

The evidence pack ties together:

  • the terms presented at issue,
  • confirmation metadata,
  • the signed claims and token,
  • each verification attempt, and
  • the final exercise event.

Use GET /v1/mandate?user_id=usr_demo_123 to list a user's mandates, newest first. Pending entries have draft claims and token: null.

Revoke when the authority should end early

Revoke works before or after confirmation and is idempotent:

Request
curl -X POST "$OEX_BASE_URL/v1/mandate/$MANDATE_ID/revoke" \
  -H "x-api-key: $OEX_API_KEY"
Response
{
  "id": "mdt_0123456789abcdef0123456789abcdef",
  "status": "revoked",
  "already_revoked": false
}

Later calls return already_revoked: true. If exercise happened first, subsequent reads remain exercised because exercise has higher status precedence.

Where to go next

On this page