Airtm Webhooks API
Webhooks are how services notify each other of events. At their core they are just a POST request to a pre-determined endpoint. The endpoint can be whatever you want, and you can just add them from the UI. You normally use one endpoint per service, and that endpoint listens to all of the event types. For example, if you receive webhooks from Airtm, you can structure your URL like: `https://www.example.com/webhooks/airtm`. The way to indicate that a webhook has been processed is by returning a 2xx (status code 200-299) response to the webhook message within a reasonable time-frame (15s). It's also important to disable CSRF protection for this endpoint if the framework you use enables them by default. Another important aspect of handling webhooks is to verify the signature and timestamp when processing them. You can learn more about it in the signature verification section. # Adding an Endpoint In order to start listening to messages, you will need to configure your endpoints. Adding an endpoint is as simple as providing a URL that you control and selecting the event types that you want to listen to. You can do this by navigating to the "Webhooks" section in the [enterprise dashboard](https://enterprise.airtm.com/settings/webhooks). If you don't specify any event types, by default, your endpoint will receive all events, regardless of type. This can be helpful for getting started and for testing, but we recommend changing this to a subset later on to avoid receiving extraneous messages. If your endpoint isn't quite ready to start receiving events, you can press the "with Svix Play" button to have a unique URL generated for you. You'll be able to view and inspect webhooks sent to your Svix Play URL, making it effortless to get started. # Testing Endpoints Once you've added an endpoint, you'll want to make sure its working. The "Testing" tab lets you send test events to your endpoint. After sending an example event, you can click into the message to view the message payload, all of the message attempts, and whether it succeeded or failed. # Verifying Signatures Webhook signatures let you verify that webhook messages are actually sent by us and not a malicious actor. For a more detailed explanation, check out this article on [why you should verify webhooks](https://docs.svix.com/receiving/verifying-payloads/why). Our webhook partner Svix offers a set of useful libraries that make verifying webhooks very simple. Here is a an example using Javascript: ```javascript import { Webhook } from 'svix'; const secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; // These were all sent from the server const headers = { 'svix-id': 'msg_p5jXN8AQM9LWM0D4loKWxJek', 'svix-timestamp': '1614265330', 'svix-signature': 'v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=', }; const payload = '{"test": 2432232314}'; const wh = new Webhook(secret); // Throws on error, returns the verified content on success const payload = wh.verify(payload, headers); ``` For more instructions and examples of how to verify signatures, check out their [webhook verification documentation](https://docs.svix.com/receiving/verifying-payloads/how). # Retries We attempt to deliver each webhook message based on a retry schedule with exponential backoff. ## The schedule Each message is attempted based on the following schedule, where each period is started following the failure of the preceding attempt: - Immediately - 5 seconds - 5 minutes - 30 minutes - 2 hours - 5 hours - 10 hours - 10 hours (in addition to the previous) If an endpoint is removed or disabled delivery attempts to the endpoint will be disabled as well. For example, an attempt that fails three times before eventually succeeding will be delivered roughly 35 minutes and 5 seconds following the first attempt. ## Manual retries You can also use the application portal to manually retry each message at any time, or automatically retry ("Recover") all failed messages starting from a given date. # Troubleshooting Tips There are some common reasons why your webhook endpoint is failing: ## Not using the raw payload body This is the most common issue. When generating the signed content, we use the raw string body of the message payload. If you convert JSON payloads into strings using methods like stringify, different implementations may produce different string representations of the JSON object, which can lead to discrepancies when verifying the signature. It's crucial to verify the payload exactly as it was sent, byte-for-byte or string-for-string, to ensure accurate verification. ## Missing the secret key From time to time we see people simple using the wrong secret key. Remember that keys are unique to endpoints. ## Sending the wrong response codes When we receive a response with a 2xx status code, we interpret that as a successful delivery even if you indicate a failure in the response payload. Make sure to use the right response status codes so we know when message are supposed to succeed vs fail. ## Responses timing out We will consider any message that fails to send a response within 15 seconds a failed message. If your endpoint is also processing complicated workflows, it may timeout and result in failed messages. We suggest having your endpoint simply receive the message and add it to a queue to be processed asynchronously so you can respond promptly and avoiding getting timed out. # Failure Recovery ## Re-enable a disabled endpoint If all attempts to a specific endpoint fail for a period of 5 days, the endpoint will be disabled. To re-enable a disabled endpoint, go to the webhook dashboard, find the endpoint from the list and select "Enable Endpoint". ## Recovering/Resending failed messages If your service has downtime or if your endpoint was misconfigured, you probably want to recover any messages that failed during the downtime. If you want to replay a single event, you can find the message from the UI and click the options menu next to any of the attempts. From there, click "resend" to have the same message send to your endpoint again. If you need to recover from a service outage and want to replay all the events since a given time, you can do so from the Endpoint page. On an endpoint's details page, click "Options > Recover Failed Messages". From there, you can choose a time window to recover from. For a more granular recovery - for example, if you know the exact timestamp that you want to recover from - you can click the options menu on any message from the endpoint page. From there, you can click "Replay..." and choose to "Replay all failed messages since this time." # IP Addresses Webhooks are sent from the following IP addresses: - `44.228.126.217` - `50.112.21.217` - `52.24.126.164` - `54.148.139.208` - `2600:1f24:64:8000::/56`
Documentation
Specifications
Other Resources
openapi: 3.2.0
info:
title: Airtm Enterprise API V2 Webhooks API
version: 1.155.0
description: "# Introduction\n\nThe Airtm Enterprise API enables organizations to send and receive payments globally through a simple REST API.\n\n## What You Can Do\n\n- **Send Payouts**: Pay employees, contractors, and partners in [190+ countries](https://help.airtm.com/en/support/solutions/articles/47001177565-where-are-airtm-s-products-and-services-available-)\n- **Collect Payments**: Accept payments from users worldwide\n- **Generate Reports**: Download transaction data and audit reports\n- **Process in Bulk**: Handle thousands of payments efficiently\n\n## Getting Started\n\n### 1. Get API Credentials\n\n[Sign up](https://airtm-product.typeform.com/to/ggXbsJpl?typeform-source=api-docs) for an Enterprise account\n\n### 2. Generate API Keys\n\nCreate keys in your [account settings](https://enterprise.airtm.com/settings)\n\n### 3. Test Your Setup\n\nMake your first API call to verify connectivity\n\n```bash\ncurl \"https://api.enterprise.airtm.com/v2/me\" \\\n -u \"your_api_key:your_secret_key\"\n```\n\n## Environments\n\n### Sandbox\n\nTesting with fake money\n\nURL: `https://api.stg.enterprise.airtm.com`\n\n### Production\n\nLive transactions\n\nURL: `https://api.enterprise.airtm.com`\n\n## Rate Limits\n\n- **10 requests per second** per API key\n- HTTP 429 response when exceeded\n- Contact support for higher limits\n\n## Security\n\n- Use HTTPS for all requests\n- Store credentials securely (never in client code)\n- Separate sandbox and production keys\n\n## Need Help?\n\n- **Technical Support**: [enterprise@airtm.com](mailto:enterprise@airtm.com)\n- **API Updates**: Follow our [Changelog](#description/changelog)\n- **Error Reference**: Check [Reason Codes](#description/reason-codes)\n\n\n---\n\n# Authentication\n\nThe Airtm Enterprise API uses HTTP Basic Authentication for all requests.\n\n## How It Works\n\nEvery API call must include an `Authorization` header with your API credentials:\n\n```text\nAuthorization: Basic <base64-encoded-credentials>\n```\n\n## Getting Your API Keys\n\n1. Log into your [dashboard](https://enterprise.airtm.com)\n2. Go to **Settings** → **API Keys**\n3. Click **Generate New API Key**\n4. Save both the API Key and Secret Key securely\n\n> **⚠️ Important**: Your Secret Key is only shown once. Store it immediately.\n\n## Setting Up Authentication\n\n### Step 1: Combine Your Credentials\n\nFormat: `api_key:secret_key`\n\nExample: `your_api_key:your_secret_key`\n\n### Step 2: Base64 Encode\n\nUse any base64 encoder or command line:\n\n```bash\necho -n \"your_api_key:your_secret_key\" | base64\n```\n\n### Step 3: Add to Request Header\n\n```text\nAuthorization: Basic <base64_encoded_credentials>\n```\n\n## Code Examples\n\n### cURL\n\n```bash\ncurl -X GET \"https://api.enterprise.airtm.com/v2/me\" \\\n -u \"your_api_key:your_secret_key\"\n```\n\n### JavaScript\n\n```javascript\nconst response = await fetch('https://api.enterprise.airtm.com/v2/me', {\n headers: {\n Authorization: 'Basic ' + btoa('your_api_key:your_secret_key'),\n },\n});\n```\n\n### Python\n\n```python\nimport requests\nfrom requests.auth import HTTPBasicAuth\n\nresponse = requests.get(\n 'https://api.enterprise.airtm.com/v2/me',\n auth=HTTPBasicAuth('your_api_key', 'your_secret_key')\n)\n```\n\n## Security Best Practices\n\n- **Store securely**: Use environment variables\n- **Server-side only**: Never expose keys in client code\n- **Rotate regularly**: Update keys every 90 days\n\n## Common Errors\n\n### 401 Unauthorized\n\n- Check your API key and secret\n- Verify base64 encoding is correct\n- Ensure you're using the right environment keys\n\n### 403 Forbidden\n\n- Contact support to verify account permissions\n- Check if your account is fully activated\n\n## Need Help?\n\nFor authentication issues:\n\n- **Email**: [enterprise@airtm.com](mailto:enterprise@airtm.com)\n- **Keys not working?** Check your [Enterprise Dashboard](https://enterprise.airtm.com/settings)\n\n\n---\n\n# OAuth 2.0 / OIDC Authorization\n\nThe Airtm Enterprise API provides an OAuth 2.0 authorization server that allows your application to act on behalf of Airtm users. This is the authentication layer for the Partner Public API.\n\nThree grant types are supported:\n\n- **Authorization Code** — for user-delegated access. The user explicitly approves which scopes your application can use.\n- **Refresh Token** — exchanges a refresh token for a new access token without re-involving the user. Issued only when the `offline_access` scope is granted.\n- **Client Credentials** — for machine-to-machine access where no user is involved.\n\n> [!IMPORTANT]\n> Authorization Code flows require PKCE (`code_challenge_method=S256`). Requests without it are rejected.\n\n---\n\n## Base URL\n\n| Environment | OIDC Issuer |\n| ----------- | ------------------------------------------- |\n| Sandbox | `https://api.stg.enterprise.airtm.com/oidc` |\n| Production | `https://api.enterprise.airtm.com/oidc` |\n\nAll endpoints below are relative to the issuer base URL.\n\n---\n\n## Scopes\n\n| Scope | Description |\n| ---------------- | ---------------------------------------------------------------------------------------- |\n| `openid` | Required for all authorization code flows. Returns an ID token. |\n| `offline_access` | Requests a refresh token. Requires `prompt=consent` in the authorization request. |\n| `wallet:receive` | Partner may send funds into this user's AirTM wallet. |\n| `wallet:send` | Partner may initiate outbound transfers from this user's AirTM wallet. |\n| `wallet:read` | Partner may read the user's AirTM wallet or channel address (read-only). |\n| `kyc:status` | Partner may verify the user's identity status and retrieve their legal name and country. |\n\n> [!IMPORTANT]\n> Scopes must be pre-approved per client during registration. Requesting an unapproved scope returns `error=invalid_scope` before the user sees any screen.\n\n---\n\n## Endpoint Reference\n\n### `GET /.well-known/openid-configuration`\n\nDiscovery document. Returns all endpoint URLs, supported scopes, grant types, and signing algorithms. The starting point for any client integrating with this server.\n\n```bash\ncurl -s https://api.enterprise.airtm.com/oidc/.well-known/openid-configuration\n```\n\n---\n\n### `GET /jwks`\n\nPublic key set (JWKS). Resource servers use this to verify token signatures offline without calling the authorization server at request time. Private key components are never included.\n\n```bash\ncurl -s https://api.enterprise.airtm.com/oidc/jwks\n```\n\n---\n\n### `GET /auth`\n\nAuthorization endpoint. Starts the authorization code flow. On a valid request, redirects the browser to the interaction login screen.\n\n| Parameter | Required | Description |\n| ----------------------- | -------- | ----------------------------------------------------------------------------------------------------- |\n| `response_type` | Yes | Must be `code` |\n| `client_id` | Yes | Your registered client ID |\n| `redirect_uri` | Yes | Must match your registered redirect URI |\n| `scope` | Yes | Space-separated list. Must include `openid` |\n| `code_challenge` | Yes | `BASE64URL(SHA-256(code_verifier))` |\n| `code_challenge_method` | Yes | Must be `S256` |\n| `prompt` | No | Include `consent` when requesting `offline_access`. Without it, `offline_access` is silently dropped. |\n\n**Error cases:**\n\n| Condition | Result |\n| ------------------------------- | -------------------------------------------- |\n| Missing PKCE | `303` → `redirect_uri?error=invalid_request` |\n| Unknown or disabled `client_id` | `400` with `error=invalid_client` |\n| Unknown scope | `303` → `redirect_uri?error=invalid_scope` |\n| Unapproved scope for client | `303` → `redirect_uri?error=invalid_scope` |\n\n> [!NOTE]\n> Disabled clients (deactivated by Airtm support) and unknown clients return the same error. Inactive status is never disclosed — request the same flow you would for a brand-new integration.\n\n---\n\n### `POST /token`\n\nToken endpoint. Exchanges a code for tokens (authorization code grant) or issues a token directly (client credentials grant).\n\n#### Authorization Code\n\n```bash\ncurl -s -X POST https://api.enterprise.airtm.com/oidc/token \\\n -d \"grant_type=authorization_code\" \\\n -d \"code=AUTH_CODE\" \\\n -d \"redirect_uri=https://yourapp.com/callback\" \\\n -d \"client_id=YOUR_CLIENT_ID\" \\\n -d \"client_secret=YOUR_CLIENT_SECRET\" \\\n -d \"code_verifier=CODE_VERIFIER\"\n```\n\n**Response:**\n\n```json\n{\n \"access_token\": \"...\",\n \"id_token\": \"...\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600,\n \"refresh_token\": \"...\",\n \"scope\": \"wallet:receive\"\n}\n```\n\n> `refresh_token` is only present when `offline_access` was included in the requested scope and `prompt=consent` was sent in the authorization request.\n>\n> `scope` reflects the resource-server scopes the token actually carries. OIDC scopes (`openid`, `offline_access`, …) are honoured via the `id_token` and refresh-token rules respectively, not via the access-token `scope` string.\n\n**Access token format.** Access tokens are opaque (not JWTs). Resource servers MUST validate them by calling [`POST /token/introspection`](#post-tokenintrospection) for each request rather than verifying a JWT signature locally. The discovery document advertises `id_token_signing_alg_values_supported`; that algorithm applies to `id_token` only.\n\n**`id_token` claims.** The `id_token` is signed (RS256). Claims are scope-gated:\n\n| Scope | Claims returned |\n| --------- | ----------------------------------------------------------------------- |\n| `openid` | `sub`, `aud`, `iat`, `exp`, `iss` |\n| `email` | `email` |\n| `profile` | `name`, `given_name`, `family_name`, `preferred_username`, `updated_at` |\n\nRequest `openid profile email` together to receive the full set on the `id_token`. Any individual claim may be `null` / absent if Dodrio doesn't have a value for it on that user.\n\n#### Refresh Token\n\nExchanges a refresh token for a new access token. No user interaction is required.\n\n```bash\ncurl -s -X POST https://api.enterprise.airtm.com/oidc/token \\\n -d \"grant_type=refresh_token\" \\\n -d \"refresh_token=REFRESH_TOKEN\" \\\n -d \"client_id=YOUR_CLIENT_ID\" \\\n -d \"client_secret=YOUR_CLIENT_SECRET\"\n```\n\n**Response:**\n\n```json\n{\n \"access_token\": \"...\",\n \"refresh_token\": \"...\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600\n}\n```\n\n> **Refresh tokens rotate on every use.** The response always contains a new `refresh_token`; the one you sent is immediately consumed. Presenting a consumed token is treated as a replay attack — the entire grant is revoked and all issued tokens for that grant stop working.\n\n#### Client Credentials\n\n```bash\ncurl -s -X POST https://api.enterprise.airtm.com/oidc/token \\\n -d \"grant_type=client_credentials\" \\\n -d \"client_id=YOUR_CLIENT_ID\" \\\n -d \"client_secret=YOUR_CLIENT_SECRET\" \\\n -d \"scope=wallet:receive\"\n```\n\n**Response:**\n\n```json\n{\n \"access_token\": \"...\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600\n}\n```\n\n> No `id_token` is returned for client credentials — there is no user in this flow.\n\n---\n\n### `POST /token/introspection`\n\nToken introspection (RFC 7662). Lets a client check whether one of its own issued tokens is still active.\n\n> [!IMPORTANT]\n> A client can only introspect tokens it issued itself. Attempting to introspect another client's token returns `{ \"active\": false }` without revealing the token's existence.\n\n```bash\ncurl -s -X POST https://api.enterprise.airtm.com/oidc/token/introspection \\\n -d \"token=ACCESS_TOKEN\" \\\n -d \"client_id=YOUR_CLIENT_ID\" \\\n -d \"client_secret=YOUR_CLIENT_SECRET\"\n```\n\n**Active token response:**\n\n```json\n{\n \"active\": true,\n \"client_id\": \"YOUR_CLIENT_ID\",\n \"exp\": 1234567890\n}\n```\n\n**Inactive or unknown token:**\n\n```json\n{ \"active\": false }\n```\n\n---\n\n### `POST /token/revocation`\n\nToken revocation (RFC 7009). Immediately invalidates a token. Same ownership rule as introspection — a client can only revoke tokens it issued.\n\n```bash\ncurl -s -X POST https://api.enterprise.airtm.com/oidc/token/revocation \\\n -d \"token=ACCESS_TOKEN\" \\\n -d \"client_id=YOUR_CLIENT_ID\" \\\n -d \"client_secret=YOUR_CLIENT_SECRET\"\n```\n\nReturns `200` with an empty body on success.\n\n> [!IMPORTANT]\n> Revoking **any** token under a grant kills every sibling token in that grant. If you revoke an access token, the paired refresh token (and any other access tokens issued under the same authorization) stops working immediately. To keep using the grant, start a fresh authorization-code flow. This matches the OAuth 2.1 expectation that a revocation request is a \"log this session out\" signal, not a \"retire one short-lived bearer\" signal.\n\n---\n\n### `GET /me`\n\nThe OIDC discovery doc advertises a `userinfo_endpoint` at `/oidc/me`, but **it is not usable with the tokens this server issues**.\n\n```bash\n# Will always return 401 invalid_token with our setup:\ncurl -s https://api.enterprise.airtm.com/oidc/me \\\n -H \"Authorization: Bearer ACCESS_TOKEN\"\n```\n\n> [!IMPORTANT]\n> Why: this server uses Resource Indicators (RFC 8707), which means every access token carries an `aud` claim (e.g. `https://api.enterprise.airtm.com`). oidc-provider's userinfo endpoint explicitly rejects any access token that has an `aud` set — its design assumption is that a userinfo-eligible token is issued for the OP itself, not for a downstream resource server.\n>\n> **Read the `id_token` instead.** It already carries every user claim this server resolves — `sub`, `email`, `name`, `given_name`, `family_name`, `preferred_username`, `updated_at` — and the set is gated by the same `openid` / `profile` / `email` scopes you'd request for `/me`. Example payload (decoded from `id_token` at `/token`):\n>\n> ```json\n> {\n> \"sub\": \"11111111-1111-1111-1111-111111111111\",\n> \"email\": \"user@example.com\",\n> \"name\": \"Alice Example\",\n> \"given_name\": \"Alice\",\n> \"family_name\": \"Example\",\n> \"preferred_username\": \"alice\",\n> \"updated_at\": 1747000000,\n> \"aud\": \"YOUR_CLIENT_ID\",\n> \"iat\": 1747000000,\n> \"exp\": 1747003600,\n> \"iss\": \"https://api.enterprise.airtm.com/oidc\"\n> }\n> ```\n>\n> Any individual claim may be `null` / absent if the upstream identity store doesn't have a value for it on that user.\n\n---\n\n### `GET /session/end` · `POST /session/end`\n\nRP-initiated logout. Ends the user's session on the authorization server. Accepts an optional `id_token_hint` to identify the session to close.\n\n```bash\ncurl -si \"https://api.enterprise.airtm.com/oidc/session/end?id_token_hint=ID_TOKEN\"\n```\n\n---\n\n## Authorization Code Flow — Step by Step\n\nThis is the canonical partner integration. Your application redirects the user to `/oidc/auth`; Airtm's hosted UI handles login and consent off-screen; the browser is sent back to your `redirect_uri` with a one-time authorization code; your backend exchanges that code for tokens.\n\nThree things to know going in:\n\n- **Your application never sees the user's credentials or the consent UI.** Airtm hosts both. The user authenticates against Dodrio and approves scopes inside Airtm's pages, not yours. You just kick off the flow and wait for the callback.\n- **PKCE is mandatory on every flow** (`code_challenge_method=S256`).\n- **The exchange step (`/oidc/token`) runs server-side.** Your `client_secret` is a credential and must not live in the SPA.\n\n### Step 1 — Generate PKCE and `state`\n\nPKCE binds the eventual code exchange to the browser session that initiated the flow. `state` protects against CSRF on the callback. Generate both freshly per request.\n\n```bash\nCODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=\\n' | tr '+/' '-_')\nCODE_CHALLENGE=$(printf '%s' \"$CODE_VERIFIER\" | openssl dgst -sha256 -binary | base64 | tr -d '=\\n' | tr '+/' '-_')\nSTATE=$(openssl rand -hex 16)\n```\n\nStore `CODE_VERIFIER` and `STATE` somewhere you can read on the callback (server session, signed httpOnly cookie, similar). Never put them in `localStorage` or in the URL.\n\n### Step 2 — Send the user to `/oidc/auth`\n\nBuild the authorization URL and navigate the browser to it as a full-page redirect (not a `fetch`).\n\n```\nhttps://api.enterprise.airtm.com/oidc/auth\n ?response_type=code\n &client_id=YOUR_CLIENT_ID\n &redirect_uri=https://yourapp.com/callback\n &scope=openid+profile+email+wallet:receive\n &code_challenge=<CODE_CHALLENGE>\n &code_challenge_method=S256\n &state=<STATE>\n```\n\nTo also receive a refresh token, request `offline_access` and add `prompt=consent`. Without `prompt=consent`, `offline_access` is silently dropped and no refresh token will be issued.\n\n```\n&scope=openid+profile+email+offline_access+wallet:receive\n&prompt=consent\n```\n\n**What happens next is invisible to your application.** Airtm's hosted UI takes over: the user signs in (password, MFA, social SSO, passkey — whatever Dodrio supports), sees a consent screen showing your application's name, logo, and the scopes you requested, and either approves or rejects. When they're done, the browser is redirected back to your `redirect_uri`.\n\n### Step 3 — Handle the callback at your `redirect_uri`\n\nThe browser returns to your `redirect_uri` with one of two query shapes.\n\n**On success:**\n\n```\nhttps://yourapp.com/callback?code=<auth-code>&state=<state>&iss=https://api.enterprise.airtm.com/oidc\n```\n\nBefore doing anything with the `code`, your callback handler must:\n\n1. Verify `state` matches what you stored in Step 1. If not, abort the flow — this is a CSRF signal.\n2. Verify `iss` matches the issuer you registered against.\n3. Treat the `code` as single-use and short-lived (10 minute TTL).\n\n**On failure:**\n\n```\nhttps://yourapp.com/callback?error=<code>&error_description=<text>&state=<state>&iss=https://api.enterprise.airtm.com/oidc\n```\n\n| Error code | Meaning |\n| ----------------- | ----------------------------------------------------------------- |\n| `access_denied` | User rejected consent or aborted the flow |\n| `invalid_request` | PKCE missing, malformed parameters, or a similar protocol error |\n| `invalid_scope` | Requested scope is unknown, or your client is not approved for it |\n| `server_error` | Server-side failure — start the flow over |\n\n> [!NOTE] > `iss` is included on **both** success and error callbacks per RFC 9207 (Authorization Server Issuer Identification). Apply the same `iss` check to error responses as to success ones — it lets you distinguish a genuine `access_denied` from this server from a forged callback pointing at your `redirect_uri`.\n\n### Step 4 — Exchange the code for tokens (server-side)\n\nThis call must be made from your backend. The `client_secret` is a credential and must never travel through the browser.\n\n```bash\ncurl -s -X POST https://api.enterprise.airtm.com/oidc/token \\\n -d \"grant_type=authorization_code\" \\\n -d \"code=$AUTH_CODE\" \\\n -d \"redirect_uri=https://yourapp.com/callback\" \\\n -d \"client_id=YOUR_CLIENT_ID\" \\\n -d \"client_secret=YOUR_CLIENT_SECRET\" \\\n -d \"code_verifier=$CODE_VERIFIER\"\n```\n\n**Response:**\n\n```json\n{\n \"access_token\": \"...\",\n \"id_token\": \"...\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600,\n \"refresh_token\": \"...\",\n \"scope\": \"wallet:receive\"\n}\n```\n\n- `id_token` is an RS256-signed JWT carrying the user's identity claims (`sub`, `email`, `name`, `given_name`, `family_name`, `preferred_username`, `updated_at`, gated by the scopes you requested). Verify the signature against the JWKS at `/oidc/jwks` and read claims from it directly — see the note under [`/me`](#get-me) for why the userinfo endpoint is not used.\n- `refresh_token` is present only when `offline_access` was requested with `prompt=consent`.\n- `scope` reflects the resource scopes the access token actually carries — `openid` / `profile` / `email` / `offline_access` are honoured via the id_token and refresh-token rules, not echoed back here.\n\n### Step 5 — Use the access token\n\nSend the access token to Airtm's [Wallet Resource API](#description/resource-api) as a Bearer credential:\n\n```bash\ncurl https://api.enterprise.airtm.com/api/connect/v1/balance \\\n -H \"Authorization: Bearer $ACCESS_TOKEN\"\n```\n\nThe Resource API is where you move funds in and out of the user's wallet, read their balance, and check KYC — see the [Wallet Resource API guide](#description/resource-api) for the full endpoint reference.\n\nIf the token is invalid, expired, or has been revoked, the resource API returns `401` with a `WWW-Authenticate: Bearer error=\"invalid_token\", error_description=\"…\"` header. If the token is valid but lacks a required scope, the response is `403` with `WWW-Authenticate: Bearer error=\"insufficient_scope\", scope=\"<required>\"`. Use the `error_description` to decide whether to refresh or restart the flow.\n\n### Step 6 — Refresh when the access token expires\n\nAccess tokens are valid for 1 hour. When a resource API returns `401 invalid_token`, exchange the refresh token for a fresh pair:\n\n```bash\ncurl -s -X POST https://api.enterprise.airtm.com/oidc/token \\\n -d \"grant_type=refresh_token\" \\\n -d \"refresh_token=$REFRESH_TOKEN\" \\\n -d \"client_id=YOUR_CLIENT_ID\" \\\n -d \"client_secret=YOUR_CLIENT_SECRET\"\n```\n\n> [!IMPORTANT] > **Refresh tokens rotate on every use.** The response always contains a new `refresh_token`; the one you sent is consumed the moment the new pair is issued. Discard the old token immediately. If you ever present a consumed refresh token, the entire grant is revoked (treated as a replay attack) and the user must re-authorize before any new tokens can be issued.\n\n---\n\n## Interaction Endpoints\n\n> [!NOTE] > **Partner integrators do not call these endpoints.** They are the contract between Airtm's hosted login / consent UI and this authorization server. The user is automatically redirected into Airtm's UI by `/oidc/auth`; partners only see the final callback at their `redirect_uri`. This section is reference material for the Airtm frontend team and for anyone debugging the in-browser interaction flow.\n\nThese endpoints are called by Airtm's hosted UI during the login and consent steps. The authorization server redirects the browser into Airtm's frontend when it needs a user decision; the frontend then calls these endpoints to read state and resume the flow.\n\n### Where the browser lands\n\nAfter a successful `GET /oidc/auth`, the authorization server 303s the browser to **your frontend** at one of two URLs, depending on which prompt is required next:\n\n| Prompt | Browser is sent to | Page to render |\n| --------- | ------------------------------------ | ------------------------------------------- |\n| `login` | `${OIDC_FRONTEND_URL}/login/<uid>` | Your existing login form (drives Dodrio) |\n| `consent` | `${OIDC_FRONTEND_URL}/consent/<uid>` | The consent screen for the requested scopes |\n\nBoth routes should fetch `GET /interaction/<uid>` and verify `prompt.name` matches the URL they're mounted under — the URL is a hint, not a contract. A user who lands on `/consent/<uid>` for an interaction that's actually at the login prompt should be navigated to `/login/<uid>` based on the GET response.\n\n### `GET /interaction/:uid`\n\nReturns the current interaction state so your UI knows which screen to show. Read-only — safe to call multiple times.\n\n```bash\ncurl -s -c \"$COOKIES\" -b \"$COOKIES\" \\\n \"https://api.enterprise.airtm.com/interaction/$OIDC_UID\"\n```\n\n**Response — login prompt:**\n\n```json\n{\n \"uid\": \"abc123\",\n \"prompt\": {\n \"name\": \"login\",\n \"reasons\": [\"no_session\"],\n \"details\": {}\n },\n \"params\": {\n \"client_id\": \"acme-corp-web\",\n \"scope\": \"openid wallet:receive\",\n \"redirect_uri\": \"https://acme.example.com/callback\",\n \"response_type\": \"code\"\n },\n \"client\": {\n \"id\": \"acme-corp-web\",\n \"name\": \"ACME Payments\",\n \"logo\": \"https://cdn.acme.example.com/airtm/logo.svg\"\n },\n \"session\": null\n}\n```\n\n**Response — consent prompt** (after login completes, the next `GET /interaction/<uid>` returns this shape; the consent screen renders the scopes listed under `prompt.details`):\n\n```json\n{\n \"uid\": \"def456\",\n \"prompt\": {\n \"name\": \"consent\",\n \"reasons\": [\"consent_prompt\", \"op_scopes_missing\", \"rs_scopes_missing\"],\n \"details\": {\n \"missingOIDCScope\": [\"openid\", \"profile\", \"email\", \"offline_access\", \"wallet:receive\"],\n \"missingResourceScopes\": {\n \"https://api.enterprise.airtm.com\": [\"wallet:receive\"]\n },\n \"scopeDescriptions\": [\n { \"scope\": \"openid\", \"description\": \"Authenticate the user and return an ID token.\" },\n {\n \"scope\": \"profile\",\n \"description\": \"Request access to the user's profile claims such as name and username.\"\n },\n { \"scope\": \"email\", \"description\": \"Request access to the user's email address.\" },\n {\n \"scope\": \"offline_access\",\n \"description\": \"Request a refresh token so your application can obtain a new access token later.\"\n },\n {\n \"scope\": \"wallet:receive\",\n \"description\": \"Partner may send funds into this user's AirTM wallet.\"\n }\n ]\n }\n },\n \"params\": {\n \"client_id\": \"acme-corp-web\",\n \"scope\": \"openid profile email offline_access wallet:receive\",\n \"redirect_uri\": \"https://acme.example.com/callback\",\n \"response_type\": \"code\"\n },\n \"client\": {\n \"id\": \"acme-corp-web\",\n \"name\": \"ACME Payments\",\n \"logo\": \"https://cdn.acme.example.com/airtm/logo.svg\"\n },\n \"session\": {\n \"accountId\": \"8a16b9c2-6ae9-4324-b644-3936fe57c8ed\"\n }\n}\n```\n\nNotes on the consent shape:\n\n- `prompt.details.missingOIDCScope` is the **union** of identity-style scopes (`openid`, `profile`, `email`, `offline_access`) _and_ resource scopes (`wallet:receive` etc.) the user hasn't yet approved on this grant.\n- `prompt.details.missingResourceScopes` is keyed by resource indicator (currently always `https://api.enterprise.airtm.com`) and lists only the resource-typed scopes from that set. The two lists overlap on resource scopes by design — the SPA should de-duplicate when rendering.\n- `prompt.details.scopeDescriptions` pairs each entry in `missingOIDCScope` with the human-readable copy stored in `oidc_allowed_scopes.description`. Render these strings on the consent screen rather than hard-coding labels per scope — the catalog is the source of truth and is the only place to edit user-facing wording. `description` is `null` for any scope that has no row in the catalog or whose `description` column is unset; the SPA should fall back to the raw scope name (or a generic line) in that case.\n- `session.accountId` is set on the consent prompt (login already completed) and is the same value that will appear as `sub` on the eventual `id_token`.\n- Wire keys on this response are returned verbatim — snake_case for OIDC fields (`client_id`, `redirect_uri`, …) and resource-indicator URLs are left as-is inside `missingResourceScopes`. The interaction router opts out of the auto-camelcase response transform so these values match the OIDC spec on the wire.\n\n`prompt.name` is either `\"login\"` or `\"consent\"`. `client` carries the display fields the consent screen needs (name, logo) so the SPA doesn't need a separate lookup.\n\n> [!NOTE]\n> The values you'll see in `client.name` and `client.logo` are whatever was registered for your client at onboarding. Both fields are surfaced from RFC 7591 standard client metadata (`client_name` and `logo_uri`).\n>\n> - `client.name` falls back to `client.id` (the wire-format `client_id`) when no human-readable display name has been registered. The frontend should still use `client.name` — it's the right field — but be prepared for it to equal the raw `client_id` for not-yet-fully-onboarded partners.\n> - `client.logo` is `null` when no logo URI has been registered. Render a generic placeholder in that case.\n\n### Registering your `client_name` and `logo_uri`\n\nThe values shown above (`ACME Payments`, `https://cdn.acme.example.com/airtm/logo.svg`) are what the consent screen renders to your users — they need to be set up at integration time. Send AirTM support the following when you register the client (or any time you want to update them):\n\n| Field | Constraint | Example |\n| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |\n| `client_name` | Human-readable, up to ~80 characters. Shown on the AirTM-hosted consent screen as **\"\\<client_name\\> wants to access your AirTM account\"**. | `ACME Payments` |\n| `logo_uri` | HTTPS URL serving an image (SVG preferred, or a square PNG ≥128×128). Must be reachable from the public internet so AirTM-hosted UIs can load it. | `https://cdn.acme.example.com/airtm/logo.svg` |\n\nUntil both are registered the consent screen will fall back to your `client_id` for the name and a generic placeholder for the logo, which most end-users find confusing. Treat this as part of the launch checklist for any new app.\n\n---\n\n### `POST /interaction/:uid/login`\n\nEstablishes the authenticated session for the OIDC flow. Accepts a Dodrio-issued JWT — not raw credentials. Your SPA is responsible for driving Dodrio's `POST /login` (including any MFA / step-up prompts) and forwarding the resulting JWT to this endpoint.\n\nThe JWT can be transported two ways. Pick whichever fits your flow:\n\n| Transport | Use when |\n| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |\n| `{ \"jwt\": \"…\" }` body | The SPA just exchanged credentials with Dodrio milliseconds ago. Standard explicit-handoff flow. |\n| `Authorization: Bearer <jwt>` header | Silent SSO — the SPA already holds a fresh Dodrio JWT (e.g. the user is logged into another AirTM surface) and skips its own login UI. |\n\nWhen both are supplied the header wins.\n\n```bash\n# Body transport (default)\ncurl -si -c \"$COOKIES\" -b \"$COOKIES\" \\\n -X POST \"https://api.enterprise.airtm.com/interaction/$OIDC_UID/login\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\\\
# --- truncated at 32 KB (121 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/airtm/refs/heads/main/openapi/airtm-webhooks-api-openapi.yml