Last updated

Send a payment

Quote, then pay — with an idempotency key that makes duplicates impossible.

This is the core flow, end to end: token → accounts → beneficiary → quote → payment. Every step below is runnable against sandbox.

1. Get an access token

Credentials go in the Authorization: Basic header, not the body; your client certificate is required on this call as on every other. Request only the scopes you need.

curl -X POST 'https://public-sandbox.tryjeeves.com/oauth/token' \
  --cert client.crt --key client.key \
  -H "Authorization: Basic $(printf '%s' "$CLIENT_ID:$CLIENT_SECRET" | base64)" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d 'grant_type=client_credentials&scope=quotes:write payments:write payments:read beneficiaries:read beneficiaries:write accounts:read'

The response is a standard RFC 6749 token response, so any OAuth 2.0 client library can read it:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "quotes:write payments:write payments:read beneficiaries:read beneficiaries:write accounts:read"
}

Read expires_in rather than hardcoding a lifetime — it's configured platform-side, and there is no refresh token. Check scope too: it reports what was actually granted, and a scope that wasn't granted is dropped from the response rather than rejected at this step — you'd otherwise discover it as a 403 on your first real call.

mTLS on every call: your client certificate is required on every request, not just this one. The remaining examples omit --cert client.crt --key client.key for brevity — add them to each call.

2. Register the beneficiary and get their IDs

The flow starts with who you pay. If the beneficiary already exists, fetch the IDs:

curl 'https://public-sandbox.tryjeeves.com/v1/beneficiaries?page=1&limit=50' \
  -H "Authorization: Bearer $JEEVES_ACCESS_TOKEN"

Take beneficiaryId and the paymentMethods[].paymentMethodId for the rail you want (spei for the Mexican local rail, wire for international wire). The beneficiary defines the corridor — Jeeves pays out to 190+ countries; USD→MXN here is just the worked example. Details and coverage: Beneficiaries.

Creating the beneficiary through the API

If the payee doesn't exist yet, create it with three calls (all described in Beneficiaries), using a token that carries beneficiaries:write:

  1. POST /v1/beneficiaries — create the person or business; you get a beneficiaryId and no payment methods yet.
  2. GET /v1/beneficiary-schemas — read which paymentMethod values the corridor offers and which bankingInformation and contactInformation fields each one needs.
  3. POST /v1/payment-methods — attach the method; the paymentMethodId you get back is the beneficiaryPaymentMethodId of your payment.

3. Pick the funding account

curl 'https://public-sandbox.tryjeeves.com/v1/product-accounts' \
  -H "Authorization: Bearer $JEEVES_ACCESS_TOKEN"

Take productAccountId and productAccountType from an active account whose currency is what you'll pay from. Details: Funding accounts.

4. Lock the rate

amount is the destination amount — what the beneficiary receives. The source amount is derived and returned.

curl -X POST 'https://public-sandbox.tryjeeves.com/v1/quotes' \
  -H "Authorization: Bearer $JEEVES_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sourceCurrency": "USD",
    "destinationCurrency": "MXN",
    "amount": "43035.00",
    "productAccountType": "self-funded"
  }'
{
  "quoteId": "9f2c1b84-3e7a-4d1f-9c05-8b6e2a4f7d13",
  "rate": "17.2140",
  "sourceCurrency": "USD",
  "sourceAmount": "2500.00",
  "destinationAmount": "43035.00",
  "destinationCurrency": "MXN",
  "createdAt": "2026-09-10T15:04:05Z",
  "expiresAt": "2026-09-10T15:09:05Z"
}

The quote is single-use and expires at expiresAt. Full semantics: Quotes & FX.

5. Create the payment

Idempotency-Key is required: generate a fresh UUID per payment attempt and store it before sending.

curl -X POST 'https://public-sandbox.tryjeeves.com/v1/payments' \
  -H "Authorization: Bearer $JEEVES_ACCESS_TOKEN" \
  -H "Idempotency-Key: 1f8a6d90-4c37-4b52-9e81-0d5a7c2b6f34" \
  -H "Content-Type: application/json" \
  -d '{
    "quoteId": "9f2c1b84-3e7a-4d1f-9c05-8b6e2a4f7d13",
    "onQuoteExpiry": "requote",
    "productAccountId": "2b6f0c94-7a15-4e83-9d20-6c8e3b5a1f79",
    "beneficiaryId": "3f1c9a52-6b7e-4d80-9a13-5c2e8f0b4d76",
    "beneficiaryPaymentMethodId": "b7d2e4a1-8c05-49f3-a6b2-1e7d9c3f5024",
    "purposeCode": "purchase_of_services",
    "reference": "Invoice 2026-0912"
  }'

reference is required, 1–140 characters. An empty string is rejected exactly as omitting it is.

All checks run before acceptance — quote freshness, beneficiary field patterns, purpose code, available funds. A 422 means nothing was created.

The idempotency contract

You doYou get
Retry with the same key and same bodyThe original result, byte for byte — never a second payment.
Reuse the key with a different body409 idempotency_conflict; nothing created.
Timeout or 5xxRetry with the same key. Generating a new key on retry is how duplicate payments happen.

Keys never expire; a key is permanently bound to its payment.

Requotes — the one rate you weren't shown

onQuoteExpiry decides what happens if the quote expired in flight. fail (default) returns 422 quote_expired. requote prices a new quote at the current rate and uses it: the destination amount is unchanged, the source amount moves. The response then carries both quoteId (the pricing quote) and originalQuoteId (the expired one). Compare them, and compare sourceAmount against what you expected — no separate event announces a requote.

{
  "paymentId": "7a3e5f21-9d64-4c18-b0a7-1f52e8c3d9b6",
  "status": "accepted",
  "quoteId": "4c81de07-15b2-4a93-8f6d-2e7c09ab5431",
  "originalQuoteId": "9f2c1b84-3e7a-4d1f-9c05-8b6e2a4f7d13",
  "rate": "17.1980",
  "sourceAmount": "2502.33",
  "destinationAmount": "43035.00",
  "createdAt": "2026-09-10T15:06:11Z"
}

Local (domestic) payments

The same flow handles payments where the source and destination currency are the same — nothing about idempotency, statuses, or webhooks changes. Example: paying a Mexican business from your MXN Bill Pay credit account (reported as jeeves-pay-credit, sent as credit-line), delivered in MXN via SPEI:

curl -X POST 'https://public-sandbox.tryjeeves.com/v1/quotes' \
  -H "Authorization: Bearer $JEEVES_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "sourceCurrency": "MXN",
    "destinationCurrency": "MXN",
    "amount": "43035.00",
    "productAccountType": "credit-line"
  }'

Then create the payment against the quote exactly as above, using the beneficiary's spei payment method. The purpose-code corridor depends on the account type, so take the purposeCode from the list for your corridor (see Purpose codes).

Purpose codes

purposeCode is mandatory on every payment, never defaulted — and it is a string identifier naming why the payment is made (e.g. purchase_of_services), never a number. Which identifiers are valid depends on the corridor: the currency you pay from, the currency and country you pay to, and the kind of account funding the payment.

Read the exact list for your corridor from purposeCodes[] in the response of GET /v1/beneficiary-schemas and send one of its code values. That list is the one payment-time validation accepts. See Beneficiaries.

Omitting purposeCode returns 400 (field listed in errors); an identifier outside the corridor's list returns 422 validation_failed.

6. Track it

The payment.status.updated webhook fires on every status change, and GET /v1/payments/{paymentId} is the authoritative state. See Tracking & webhooks.