This is the abridged developer documentation for ElasticPay
# Authentication
> Learn how to authenticate with the ElasticPay API.
## Key types | Type | Prefix | Use | | --------------- | ------ | ---------------------------------------------------------- | | Secret key | `sk_` | Server-side only - full API access | | Publishable key | `pk_` | Client-side - widget initialisation and tokenisation only | | OAuth token | `oa_` | Platform integrations acting on behalf of a biller account | Never expose a secret key in browser code, mobile apps, or source control. ## Sending a secret key Pass your secret key as a Bearer token in the `Authorization` header: * cURL ```bash curl https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` * Node.js ```js const response = await fetch('https://api.elasticpay.co/api/v1/payment_intents', { headers: { 'Authorization': 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' }, }); ``` * Python ```python import requests response = requests.get( 'https://api.elasticpay.co/api/v1/payment_intents', headers={'Authorization': 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'}, ) ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => ['Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'], ]); $response = $client->get('/api/v1/payment_intents'); ``` * Ruby ```ruby require 'faraday' conn = Faraday.new('https://api.elasticpay.co') conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' response = conn.get('/api/v1/payment_intents') ``` * C# ```csharp using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var response = await http.GetAsync("/api/v1/payment_intents"); ``` ## Client secret header When confirming a payment from the client side with a publishable key, include the payment intent’s `client_secret` in `X-Client-Secret`: * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123/confirm \ -H "Authorization: Bearer pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "X-Client-Secret: pi_0abc123_secret_xyz987" \ -H "Content-Type: application/json" \ -d '{"payment_method": "pm_0xyz789"}' ``` * Node.js ```js const response = await fetch( 'https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123/confirm', { method: 'POST', headers: { 'Authorization': 'Bearer pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'X-Client-Secret': 'pi_0abc123_secret_xyz987', 'Content-Type': 'application/json', }, body: JSON.stringify({ payment_method: 'pm_0xyz789' }), } ); ``` * Python ```python response = requests.post( 'https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123/confirm', headers={ 'Authorization': 'Bearer pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'X-Client-Secret': 'pi_0abc123_secret_xyz987', }, json={'payment_method': 'pm_0xyz789'}, ) ``` * PHP ```php $response = $client->post('/api/v1/payment_intents/pi_0abc123/confirm', [ 'headers' => [ 'Authorization' => 'Bearer pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'X-Client-Secret' => 'pi_0abc123_secret_xyz987', ], 'json' => ['payment_method' => 'pm_0xyz789'], ]); ``` * Ruby ```ruby response = conn.post('/api/v1/payment_intents/pi_0abc123/confirm') do |req| req.headers['Authorization'] = 'Bearer pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' req.headers['X-Client-Secret'] = 'pi_0abc123_secret_xyz987' req.body = { payment_method: 'pm_0xyz789' }.to_json end ``` * C# ```csharp http.DefaultRequestHeaders.Add("X-Client-Secret", "pi_0abc123_secret_xyz987"); var response = await http.PostAsJsonAsync( "/api/v1/payment_intents/pi_0abc123/confirm", new { payment_method = "pm_0xyz789" }); ``` The widget handles this automatically - you only need this header if calling confirm directly from the browser. ## OAuth tokens OAuth tokens (`oa_...`) act on behalf of multiple biller accounts. Include `X-Biller-Account` with every request: * cURL ```bash curl https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer oa_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "X-Biller-Account: biller_abc123" ``` * Node.js ```js const response = await fetch('https://api.elasticpay.co/api/v1/payment_intents', { headers: { 'Authorization': 'Bearer oa_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'X-Biller-Account': 'biller_abc123', }, }); ``` * Python ```python response = requests.get( 'https://api.elasticpay.co/api/v1/payment_intents', headers={ 'Authorization': 'Bearer oa_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'X-Biller-Account': 'biller_abc123', }, ) ``` * PHP ```php $response = $client->get('/api/v1/payment_intents', [ 'headers' => [ 'Authorization' => 'Bearer oa_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'X-Biller-Account' => 'biller_abc123', ], ]); ``` * Ruby ```ruby response = conn.get('/api/v1/payment_intents') do |req| req.headers['Authorization'] = 'Bearer oa_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' req.headers['X-Biller-Account'] = 'biller_abc123' end ``` * C# ```csharp http.DefaultRequestHeaders.Add("X-Biller-Account", "biller_abc123"); // Authorization already set with oa_ token var response = await http.GetAsync("/api/v1/payment_intents"); ``` ## Key formats | Type | Format | | ------------------- | -------------------------------- | | Secret sandbox | `sk_sandbox_[32 alphanumeric]` | | Secret live | `sk_live_[32 alphanumeric]` | | Publishable sandbox | `pk_sandbox_[32 alphanumeric]` | | Publishable live | `pk_live_[32 alphanumeric]` | | OAuth | `oa_sandbox_...` / `oa_live_...` | ## Keeping keys safe * Use `pk_` keys on the client side - they are scoped to tokenisation only and cannot charge or refund. * Never commit keys to source control. Use environment variables or a secrets manager. * Rotate keys immediately if you suspect exposure. Revocation takes effect instantly.
# Get a sandbox API key
> How to get an ElasticPay sandbox API key — including how an AI coding agent should hand the step off to a human.
Everything in these docs needs an API key. Sandbox keys are free, take about two minutes to obtain, and move no real money. ## Get your key 1. **Create a free account** at [dashboard.elasticpay.co](https://dashboard.elasticpay.co/users/sign_up?return_to=/developers). Email and password — no business details or credit card yet. 2. **Set up your sandbox account.** One short form. Sandbox is a full copy of the platform that moves no real money. 3. **Copy your key.** You land on **Developer → API keys**. Click **Reveal** on the sandbox secret key and copy it. You can return any time at [dashboard.elasticpay.co/developers](https://dashboard.elasticpay.co/developers). You get two sandbox keys: | Key | Prefix | Use | | ----------- | ------------- | ------------------------------------------------------------------------------------ | | Secret | `sk_sandbox_` | Server-side requests. Keep it out of browsers, mobile apps, and public repositories. | | Publishable | `pk_sandbox_` | Client-side use — safe to expose. | ## Make your first request ```bash curl https://api.elasticpay.co/api/v1/customers \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` A `200` response means the key works. A `401` means it was rejected — check you copied the whole key. ## Next steps * [Quick Start](/api/getting-started/quick-start/) — create and confirm your first payment * [Test mode](/api/getting-started/test-mode/) — how sandbox differs from live * [Test cards](/api/resources/test-cards/) — fixture tokens and card numbers that produce specific outcomes * [Test clocks](/api/guides/test-clocks/) — fast-forward time to verify recurring billing ## Going live Live keys are issued separately, after a business verification (KYB). Sandbox and live are entirely separate environments: sandbox data never appears in live, and a sandbox key can never charge a real card.
# Quick Start
> Get up and running with ElasticPay in minutes.
## Before you begin You need an ElasticPay account and sandbox API keys. Find them under **Developer → API keys** in the dashboard, or go straight to [dashboard.elasticpay.co/developers](https://dashboard.elasticpay.co/developers). If you don’t have an account yet, see [Get a sandbox API key](/api/getting-started/get-a-sandbox-key/). Keys come in two types: secret (`sk_sandbox_...`) for server-side use and publishable (`pk_sandbox_...`) for client-side use. ## Create a payment intent A payment intent represents a single payment attempt. Create one server-side with an `amount` (in cents) and `currency`. * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"amount": 2000, "currency": "AUD"}' ``` * Node.js ```js const response = await fetch('https://api.elasticpay.co/api/v1/payment_intents', { method: 'POST', headers: { 'Authorization': 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type': 'application/json', }, body: JSON.stringify({ amount: 2000, currency: 'AUD' }), }); const pi = await response.json(); console.log(pi.id); // pi_0abc123... console.log(pi.client_secret); // pass to the widget ``` * Python ```python import requests response = requests.post( 'https://api.elasticpay.co/api/v1/payment_intents', headers={'Authorization': 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'}, json={'amount': 2000, 'currency': 'AUD'}, ) pi = response.json() print(pi['id']) # pi_0abc123... print(pi['client_secret']) # pass to the widget ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $response = $client->post('/api/v1/payment_intents', [ 'json' => ['amount' => 2000, 'currency' => 'AUD'], ]); $pi = json_decode($response->getBody(), true); echo $pi['id']; // pi_0abc123... echo $pi['client_secret']; // pass to the widget ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.post('/api/v1/payment_intents') { |r| r.body = { amount: 2000, currency: 'AUD' } } pi = res.body puts pi['id'] # pi_0abc123... puts pi['client_secret'] # pass to the widget ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var response = await http.PostAsJsonAsync( "/api/v1/payment_intents", new { amount = 2000, currency = "AUD" }); var pi = await response.Content.ReadFromJsonAsync(); Console.WriteLine(pi.RootElement.GetProperty("id").GetString()); Console.WriteLine(pi.RootElement.GetProperty("client_secret").GetString()); ``` Response: ```json { "id": "pi_0abc123def456ghi789jkl012mn", "amount": 2000, "currency": "AUD", "status": "requires_payment_method", "client_secret": "pi_0abc123def456ghi789jkl012mn_secret_xyz987", "livemode": false, "created_at": "2025-01-15T10:00:00Z" } ``` The `client_secret` is passed to the widget to collect card details. Keep the payment intent `id` on your server. ## Confirm the payment After the widget tokenises the card and returns a `pm_xxx`, confirm the payment: * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456/confirm \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"payment_method": "pm_0xyz789abc123def456ghi012jkl"}' ``` * Node.js ```js const res = await fetch( 'https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456/confirm', { method: 'POST', headers: { 'Authorization': 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type': 'application/json', }, body: JSON.stringify({ payment_method: 'pm_0xyz789abc123def456ghi012jkl' }), } ); const result = await res.json(); console.log(result.status); // 'succeeded' ``` * Python ```python result = requests.post( 'https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456/confirm', headers={'Authorization': 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'}, json={'payment_method': 'pm_0xyz789abc123def456ghi012jkl'}, ).json() print(result['status']) # 'succeeded' ``` * PHP ```php $result = json_decode($client->post( '/api/v1/payment_intents/pi_0abc123def456/confirm', ['json' => ['payment_method' => 'pm_0xyz789abc123def456ghi012jkl']] )->getBody(), true); echo $result['status']; // 'succeeded' ``` * Ruby ```ruby res = conn.post('/api/v1/payment_intents/pi_0abc123def456/confirm') do |r| r.body = { payment_method: 'pm_0xyz789abc123def456ghi012jkl' } end puts res.body['status'] # 'succeeded' ``` * C# ```csharp var confirm = await http.PostAsJsonAsync( "/api/v1/payment_intents/pi_0abc123def456/confirm", new { payment_method = "pm_0xyz789abc123def456ghi012jkl" }); var result = await confirm.Content.ReadFromJsonAsync(); Console.WriteLine(result.RootElement.GetProperty("status").GetString()); // succeeded ``` A `status` of `succeeded` means the payment is complete. For async processing you may see `processing` — listen for the `payment_intent.succeeded` webhook as the authoritative signal. ## Next steps * [Authentication](/api/getting-started/authentication) — key types and how to send them * [Take a One-Off Payment](/api/use-cases/take-a-one-off-payment) — the full integration path * [Webhooks](/api/guides/webhooks) — receive real-time payment events
# Test Mode
> Use test mode to build and test your integration safely.
## How test mode works Test mode is determined by the key you use — there is no separate flag or environment to select. A request made with a `_sandbox_` key runs in test mode. A request made with a `_live_` key runs in live mode. ```bash # Test mode Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Live mode Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ## Sandbox vs live keys Both key types exist for both modes: | Mode | Secret key | Publishable key | | ------- | ---------------- | ---------------- | | Sandbox | `sk_sandbox_...` | `pk_sandbox_...` | | Live | `sk_live_...` | `pk_live_...` | Find your keys under **Developer → API keys** in the dashboard, or go straight to [dashboard.elasticpay.co/developers](https://dashboard.elasticpay.co/developers). ## Test cards Use test card numbers to simulate payment outcomes. See [Test Cards](/api/resources/test-cards) for all accepted values. For BECS direct debit testing, use BSB `062-000` with any account number. ## Simulating time Sandbox mode includes [test clocks](/api/guides/test-clocks) — fast-forward time for a set of test customers to watch payment plans renew, retries fire, and links expire in seconds instead of weeks. ## Behavior in test mode * PSP calls are mocked — no real card processing occurs * Webhook events fire identically to live mode * API request and response shapes are identical * Data is isolated — test records are not visible in live mode ## Moving to live 1. Replace all `_sandbox_` keys with `_live_` keys 2. Ensure your live account has been activated. There is no compliance settings screen — while the account is awaiting activation, the dashboard shows a **Continue onboarding** banner that opens the onboarding application, and that application shows the current review status. When the banner is gone, the account is active 3. Ask support to register your production webhook endpoint against the live account — sandbox subscriptions do not carry over See [Test vs Live](/api/concepts/test-vs-live) for the full switchover checklist. Caution Never use live keys in development or CI. Create a dedicated set of sandbox keys for automated testing.
# Start With Your Use Case
> Find the right integration path for what you're building.
The fastest way through the docs is to start from **what you’re trying to do**, then pick **how you’ll collect payment details** (the [channel](/api/channels)), which determines **what the customer can pay with** (the [method](/api/payment-methods)). ## Use cases | Use case | Channels | Methods | | ----------------------------------------------------------------------------- | ---------------------------- | ------------------------ | | [Take a one-off payment](/api/use-cases/take-a-one-off-payment) | Hosted · Embedded · Direct | Card · BECS | | [Save a payment method](/api/use-cases/save-a-payment-method) | Embedded · Direct | Card · BECS | | [Charge a saved payment method](/api/use-cases/charge-a-saved-payment-method) | Direct | Card · BECS | | [Recurring without a plan](/api/use-cases/recurring-without-a-plan) | Direct | Card · BECS | | [Recurring with a payment plan](/api/use-cases/recurring-with-a-payment-plan) | Direct + Portal | Card · BECS | | [Virtual terminal](/api/use-cases/virtual-terminal) | Embedded (internal) · Direct | Card · BECS (saved only) | ## Why this order matters The use case determines what must be captured **at the moment of authorisation** — customer identity, consent to future charges, mandate acceptance. Capture the right information up front and every later charge (recurring, saved-method, plan-generated) processes under the correct card-scheme and direct-debit rules automatically. See [Stored Credentials & Recurring Charges](/api/concepts/stored-credentials) for what ElasticPay handles for you.
# Charge a Saved Payment Method
> Charge a stored card or bank account without the customer present.
**Channels:** Direct **Methods:** Card · BECS Once a method is [saved](/api/use-cases/save-a-payment-method), you can charge it entirely server-side — the customer doesn’t need to be present. This is a **merchant-initiated transaction (MIT)**: you initiate it under the agreement the customer made when the method was saved. ## Create and confirm ```bash # 1. Create the payment intent against the customer curl -X POST https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"amount": 4900, "currency": "AUD", "customer_id": "cus_0abc123def456"}' # 2. Confirm with the saved payment method curl -X POST https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123/confirm \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"payment_method": "pm_0xyz789abc123def456ghi012jkl"}' ``` Add an `Idempotency-Key` header so a retry can never double-charge — see [Direct API Integration](/api/guides/direct-api-integration#idempotency). ## Rely on webhooks For merchant-initiated charges, the webhook is the outcome — there’s no customer watching a spinner. Listen for `payment_intent.succeeded` and `payment_intent.failed`. BECS charges stay `processing` until the batch resolves and can dishonour days later. ## What ElasticPay handles for you Because the credential was established correctly at save time, each charge is automatically submitted with the right scheme evidence — stored-credential flags and the network transaction reference from the original authorisation for cards, the mandate for BECS. You don’t pass any of this; it’s threaded through from the save. Details: [Stored Credentials & Recurring Charges](/api/concepts/stored-credentials). ## Your responsibilities * Only charge within the agreement the customer accepted (amount, frequency, purpose) * Keep your own record of that agreement — it’s your evidence in a dispute * Handle `failed` outcomes with a sensible retry policy; don’t hammer a declined card or a dishonoured account
# Recurring With a Payment Plan
> Let ElasticPay generate and process recurring charges on a schedule.
**Channels:** Direct + Portal **Methods:** Card · BECS A **payment plan** is recurring billing where ElasticPay does the work: you define the schedule (weekly, monthly), and the platform generates each payment intent, processes it against the customer’s saved method, and runs failure handling and retry logic for you. ## The pieces 1. **A customer with a saved payment method** — see [Save a Payment Method](/api/use-cases/save-a-payment-method). For BECS this includes the direct debit mandate. 2. **A plan** — created via the [Payment Plans API](/api/guides/payment-plans-api) or in the [merchant portal](/portal/payment-plans/creating-plans). 3. **Webhooks** — each generated charge emits the normal `payment_intent.*` events, so your system stays in sync without polling. ## Create a plan via the API ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_plans \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "cus_0abc123def456ghi789jkl012mno", "name": "Monthly gym membership", "frequency_type": "monthly", "recurring_amount_cents": 4900, "currency": "AUD", "start_date": "2026-09-01", "until_further_notice": true }' ``` The full lifecycle — amend, suspend, resume, close — is covered in the [Payment Plans API](/api/guides/payment-plans-api) reference, and [Understanding Plans](/portal/payment-plans/understanding-plans) explains the concepts. ## Testing plans without waiting In sandbox, attach the customer to a [test clock](/api/guides/test-clocks) and advance it — renewals, retries, and rollovers fire in seconds instead of weeks. ## Failure handling Plans come with built-in failure recovery and retry logic — see [Handling Failures](/portal/payment-plans/handling-failures). Your integration only needs to listen for the webhook outcomes. ## Plan charges are merchant-initiated Every plan-generated charge runs under the consent captured when the method was saved — the card-scheme stored-credential evidence and BECS mandate are attached automatically. See [Stored Credentials & Recurring Charges](/api/concepts/stored-credentials).
# Recurring Without a Plan
> Run your own billing schedule against saved payment methods.
**Channels:** Direct **Methods:** Card · BECS You don’t need a payment plan to bill repeatedly. If your billing logic lives in your own system — usage-based charges, irregular invoicing, per-order billing for repeat customers — just [charge the saved method](/api/use-cases/charge-a-saved-payment-method) whenever your system decides it’s time. ## When to choose this over a payment plan | Your billing is… | Use | | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | A fixed amount on a fixed cycle (e.g. $49/month) | [Payment plan](/api/use-cases/recurring-with-a-payment-plan) — scheduling, retries, and failure handling are built in | | Variable amounts, usage-based, or on your own trigger | This pattern — you decide when and how much | | A single known future charge | [Scheduled payment](/api/guides/scheduled-payments) — one intent with a future date | ## The pattern 1. [Save the payment method](/api/use-cases/save-a-payment-method) once, with consent that covers ongoing charges 2. When your system determines a charge is due, create + confirm a payment intent with the saved `pm_xxx` and an `Idempotency-Key` (e.g. your invoice ID) so reruns are safe 3. React to webhooks: `payment_intent.succeeded` closes the invoice, `payment_intent.failed` drives your retry/dunning logic ## Retries and dunning are yours Unlike payment plans, nothing retries automatically. Own your policy: * space retries out (e.g. 3 attempts over a week) rather than retrying immediately * for BECS, wait for the dishonour before retrying — a `processing` intent is not a failure * tell the customer when a charge fails; an updated card or account fixes most failures Every charge you make this way is a merchant-initiated transaction under the consent captured at save time — ElasticPay attaches the scheme and mandate evidence automatically. See [Stored Credentials & Recurring Charges](/api/concepts/stored-credentials).
# Save a Payment Method
> Store a card or bank account for later charges with a Setup Intent.
**Channels:** Embedded · Direct **Methods:** Card · BECS Saving a payment method means collecting details **once**, with the customer’s consent, and receiving a reusable token (`pm_xxx`) attached to a customer record. There are two paths. ## Path A — Save without charging (Setup Intent) Use this when no money should move yet: free trials, account setup, capturing details for future invoices. ```bash curl -X POST https://api.elasticpay.co/api/v1/setup_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"customer_id": "cus_0abc123def456"}' ``` Pass the returned `client_secret` to the [widget](/api/widget/embedding) (card or BECS variant). The widget collects the details; when the flow completes, `setup_intent.succeeded` fires and the saved `pm_xxx` is available on the customer record. For cards, ElasticPay verifies the card and establishes the stored credential with the card schemes behind the scenes — no charge appears for the customer. For BECS, completing the widget flow establishes the [direct debit mandate](/api/payment-methods/becs-direct-debit#the-mandate). ## Path B — Save during a first purchase If the customer is paying now *and* opting to save their details, don’t run two flows — create the payment intent with a `customer_id` and the `store_payment_method` metadata flag, then let them pay through the widget as normal. The first purchase itself establishes the stored credential: ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "amount": 5000, "currency": "AUD", "customer_id": "cus_0abc123def456", "metadata": {"store_payment_method": "true"} }' ``` Both pieces matter: the metadata flag (the string `"true"`) tells ElasticPay to store the credential when the purchase succeeds, and the `customer_id` is who the saved method is attached to. Set them at **create time** — the flag is not a confirm parameter. ## What you must have first Saving a method is a **consent event**. Before you save: * the customer must agree to their details being stored and to the charges you intend to make (one-off later, recurring, usage-based) * attach the method to a `customer_id` so future charges are traceable to that agreement ElasticPay records the scheme- and mandate-level evidence automatically — see [Stored Credentials & Recurring Charges](/api/concepts/stored-credentials). ## Using the saved method * [Charge a saved payment method](/api/use-cases/charge-a-saved-payment-method) — one-off * [Recurring without a plan](/api/use-cases/recurring-without-a-plan) — your own schedule * [Recurring with a payment plan](/api/use-cases/recurring-with-a-payment-plan) — ElasticPay schedules * [Scheduled payments](/api/guides/scheduled-payments) — a single future-dated charge ## Managing saved methods List a customer’s saved methods: ```bash curl https://api.elasticpay.co/api/v1/customers/cus_0abc123def456/payment_methods \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ```
# Take a One-Off Payment
> Accept a single online payment by card or BECS Direct Debit.
**Channels:** Hosted · Embedded · Direct **Methods:** Card · BECS The core flow behind every one-off payment: 1. Create a **payment intent** on your server 2. Collect payment details through your chosen channel 3. Confirm the payment 4. Treat the `payment_intent.succeeded` webhook as the source of truth ## Step 1 — Create a payment intent ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"amount": 5000, "currency": "AUD"}' ``` Response: ```json { "id": "pi_0abc123def456ghi789jkl012mn", "status": "requires_payment_method", "client_secret": "pi_0abc123def456ghi789jkl012mn_secret_xyz987", "amount": 5000, "currency": "AUD" } ``` Pass the `client_secret` to your front end — it lets the widget confirm the payment without exposing your secret key. ## Step 2 — Collect payment details Pick a [channel](/api/channels): * **[Hosted payment page](/api/channels/hosted-payment-page)** — form-POST the customer to ElasticPay; no front-end code. Card only. * **[Embedded widget](/api/widget/embedding)** — mount the card widget (or the [BECS widget](/api/widget/embedding#becs-widget)) on your page. The widget tokenises the details and returns a `pm_xxx`. * **[Direct API](/api/guides/direct-api-integration)** — you already hold a tokenised `pm_xxx` (e.g. a saved method) and confirm server-side. ## Step 3 — Confirm the payment ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/confirm \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"payment_method": "pm_0xyz789abc123def456ghi012jkl"}' ``` Check `status` in the response: | Status | Meaning | | ----------------- | -------------------------------------------------- | | `succeeded` | Payment complete | | `processing` | Pending — normal for BECS; wait for the webhook | | `requires_action` | 3D Secure challenge (card) — the widget handles it | | `failed` | Declined | For BECS, `processing` can last days and can still end in a dishonour — see [BECS Direct Debit](/api/payment-methods/becs-direct-debit). ## Step 4 — Listen for webhooks `payment_intent.succeeded` is the authoritative completion signal; don’t rely on the synchronous response alone. See [Webhooks](/api/guides/webhooks). ## Also saving the method? If the customer agrees to keep their card on file, create the intent with a `customer_id` and `"metadata": {"store_payment_method": "true"}` — the first purchase then establishes the stored credential in the same transaction. See [Save a Payment Method](/api/use-cases/save-a-payment-method).
# Virtual Terminal
> Take phone and back-office payments without handling card data.
**Channels:** Embedded (internal page) · Direct **Methods:** Card · BECS (saved methods only) A virtual terminal is a payment your **staff** initiate — a phone order, an invoice paid over the counter, a service business charging after the work is done. The customer isn’t on your website; your operator is. ## The golden rule Even internally, **never key card numbers into your own forms, CRMs, or spreadsheets** — that puts your business in full PCI scope. The safe patterns below keep card data inside ElasticPay-controlled surfaces. ## Pattern A — Internal page with the embedded widget Build a simple internal page (behind your staff login) that [embeds the widget](/api/widget/embedding). Your operator: 1. Creates the payment intent (your page calls your server, which calls the API with the amount) 2. Keys the card details the customer reads out **into the widget** — the details are tokenised inside the ElasticPay frame and never touch your systems 3. Confirms — the result shows on the page and the webhook closes it out This pattern is card-only. A BECS mandate must be accepted by the **customer** — the acceptance evidence records who agreed to the Direct Debit Request, so staff can’t key bank details on a customer’s behalf. To take BECS from a virtual-terminal context, use Pattern B against an existing mandate, or have the customer complete the [BECS widget](/api/widget/embedding#becs-widget) flow themselves. ## Pattern B — Charge a saved method For repeat phone customers, save the method once (Pattern A plus `customer_id`, or a [Setup Intent](/api/use-cases/save-a-payment-method)), then each subsequent order is just [charging the saved method](/api/use-cases/charge-a-saved-payment-method) — no card details read out over the phone at all. This is also the only way BECS works here: once the customer has accepted a [direct debit mandate](/api/payment-methods/becs-direct-debit#the-mandate), your staff can charge it like any saved method. ## Pattern C — Send the customer a payment link If the customer can use a browser mid-call, create the intent and send them into the [hosted payment page](/api/channels/hosted-payment-page) — they enter their own details, and you watch for the webhook.
# Choosing a Channel
> Hosted, embedded, or direct API — how to collect payment details.
A **channel** is how payment details get from your customer to ElasticPay. All three channels produce the same thing — a tokenised payment method (`pm_xxx`) confirmed against a payment intent — so your server-side integration is identical whichever you choose. ## The three channels | Channel | You build | Best for | | -------------------------------------------------------- | --------------------------------------- | --------------------------------------- | | [Hosted payment page](/api/channels/hosted-payment-page) | A form POST redirect | Fastest integration; no front-end work | | [Embedded widget](/api/widget/embedding) | A container element + a few lines of JS | Keeping customers on your page | | [Direct API](/api/guides/direct-api-integration) | Server-to-server calls with tokens | Back-office flows, custom architectures | ## PCI implications The channel determines how card data flows, which determines your PCI scope: | Channel | Card data touches | Your PCI scope | | ------------------- | --------------------------------------------------------------------------- | ------------------------ | | Hosted payment page | Never your site — customer pays on an ElasticPay page | SAQ A | | Embedded widget | An ElasticPay-controlled frame inside your page — never your DOM or servers | SAQ A | | Direct API | Never — you only handle `pm_xxx` tokens | Tokens are not card data | “Embedded” here means something precise: the widget renders card inputs inside an ElasticPay-controlled frame. Your page hosts the frame but can never read the card number. That boundary is what keeps you at SAQ A — see [Security & PCI Compliance](/api/concepts/security-pci). Caution Never build your own card form and post card numbers to your server or ours. That single decision moves you into full PCI DSS scope. ## Method support by channel | Channel | Card | BECS Direct Debit | | ------------------- | --------------------------- | ------------------------------------- | | Hosted payment page | ✅ | — (use the BECS widget or direct API) | | Embedded widget | ✅ `ElasticPayCardWidget` | ✅ `ElasticPayBecsWidget` | | Direct API | ✅ (tokenised `pm_xxx` only) | ✅ | ## Choosing * **Just want to get paid?** Start with the [hosted payment page](/api/channels/hosted-payment-page). * **Want the payment form on your own page?** Use the [embedded widget](/api/widget/embedding). * **Charging saved methods, back-office initiation, or building your own UI around tokens?** Use the [direct API](/api/guides/direct-api-integration). Whichever you pick, start from your [use case](/api/use-cases) — it tells you which channel and method combination fits.
# Hosted Payment Page
> Redirect customers to an ElasticPay-hosted page to complete payment.
## Overview The hosted payment page is the fastest way to accept a card payment: create a payment intent server-side, POST the customer to ElasticPay, and we handle card collection, 3D Secure, and confirmation. The customer returns to your site when they’re done. No front-end payment code, no PCI exposure beyond SAQ A. ## Step 1 — Create a payment intent ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"amount": 5000, "currency": "AUD"}' ``` Keep the returned `id` and `client_secret`. ## Step 2 — Send the customer to the checkout The hosted checkout expects a browser form POST (it sets an encrypted session cookie and 303-redirects to the checkout page): ```html ``` | Field | Purpose | | ---------------- | ------------------------------------------------- | | `payment_intent` | The intent `id` from step 1 | | `client_secret` | The intent’s `client_secret` | | `pk` | Your publishable key | | `return_url` | Where the customer lands after completing payment | | `cancel_url` | Where the customer lands if they abandon checkout | ## Step 3 — Handle the return After payment, the customer is redirected to `return_url` with the payment intent ID as a query parameter. Treat this as a navigation event, not proof of payment — fetch the intent or wait for the webhook. ## Step 4 — Confirm via webhook `payment_intent.succeeded` is the authoritative signal that money moved. See [Webhooks](/api/guides/webhooks) for signature verification.
# Payment Lifecycle
> Understand how a payment moves through ElasticPay states.
## State diagram ```plaintext ┌──────────────────────────┐ │ requires_payment_method │◄── (created) └─────────────┬────────────┘ │ payment method attached ▼ ┌──────────────────────────┐ │ requires_confirmation │ └─────────────┬────────────┘ │ confirm called ┌────────────┴─────────────────────┐ │ │ │ scheduled_payment_date │ no schedule / date = today │ is in the future │ ▼ ▼ ┌──────────────┐ ┌──────────────────┴──────────────┐ │ ready │ │ │ │ (scheduled) │ ▼ ▼ └──────┬───────┘ ┌────────────────┐ ┌────────────────────────┐ │ │ processing │ │ requires_action │ process_at │ │ (PSP pending) │ │ (3DS challenge) │ reached │ └───────┬────────┘ └────────────┬───────────┘ │ │ │ ▼ ┌──────┴──────┐ ┌──────┴──────┐ (joins processing ▼ ▼ ▼ ▼ flow above) ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │succeeded │ │ failed │ │succeeded │ │ failed │ └─────┬────┘ └──────────┘ └─────┬────┘ └──────────┘ │ full refund │ full refund ▼ ▼ ┌──────────┐ ┌──────────┐ │ refunded │ │ refunded │ └──────────┘ └──────────┘ Any non-terminal state → canceled (terminal) ``` ## State descriptions ### `requires_payment_method` The initial state for all payment intents created via the API. No payment method has been attached. The intent stays here until a `payment_method` is provided at confirm time. ### `requires_confirmation` A payment method has been attached but confirm has not been called. In most flows this state is transient — you attach the payment method and call confirm in the same step. ### `ready` The payment intent has been confirmed with a future `scheduled_payment_date`. No money moves yet. The intent is queued — on the scheduled date, `EnqueueScheduledPaymentIntentsJob` picks it up and submits it for processing. A `ready` intent can still be canceled before its scheduled date. See [Scheduled Payments](/api/guides/scheduled-payments) for details on creating scheduled intents. ### `requires_action` The payment requires an additional customer action, typically a 3D Secure authentication challenge. The widget handles this automatically. The intent transitions to `processing` after the action completes, or `failed` if the customer abandons the challenge. ### `processing` The payment has been submitted to the PSP and is awaiting a final result. This state can last from milliseconds to several business days depending on the payment method. ### `succeeded` The payment completed successfully. Terminal state — the customer has been charged. Partial refunds keep the intent in `succeeded`; only a full refund transitions to `refunded`. ### `refunded` All captured funds have been refunded. The intent transitions here automatically when the total refunded amount equals the original charge. Terminal state. ### `failed` The payment was declined or failed for another reason. Check the failure code for details. Terminal state — create a new payment intent to retry. ### `canceled` The payment intent was explicitly canceled. Terminal state. ## What to build on * **Use webhooks, not polling.** The synchronous confirm response may show `processing` or `ready` — do not assume success until you receive `payment_intent.succeeded`. * **`succeeded` is authoritative.** Build your fulfillment logic on this webhook event. * **`failed` is permanent.** Create a new payment intent to retry a failed payment. * **`ready` is not charged yet.** A `ready` response means the payment is scheduled, not collected. Monitor `payment_intent.succeeded` to confirm funds were captured. ## Refund states After a payment intent reaches `succeeded`, refunds are tracked as separate objects. A **partial** refund keeps the payment intent in `succeeded`. A **full** refund (total refunded equals original charge) transitions the payment intent to `refunded`. Use the refund object’s `status` (`succeeded`, `pending`, `failed`) and the payment intent’s `status` to track state.
# Security & PCI Compliance
> How ElasticPay keeps your payments secure and PCI compliant.
## PCI compliance model ElasticPay is PCI DSS compliant. When you use the hosted checkout or the widget, card data never touches your servers — it flows directly from the customer’s browser to the ElasticPay Cardholder Data Environment (CDE) and is tokenized there. Your PCI scope is minimal: you receive a payment method token (`pm_xxx`), not raw card data. ## CDE architecture The ElasticPay architecture separates card data handling from business logic: | Component | Role | PCI scope | | -------------------------------- | ---------------------------------- | --------------------------------------------------- | | `worker-pay` (Cloudflare Worker) | Card tokenization, PSP integration | Managed by ElasticPay | | `client-pay` (Cloudflare Worker) | Widget serving, hosted checkout | Managed by ElasticPay | | Your backend | Business logic, payment initiation | Out of scope (when using widget or hosted checkout) | Your backend only ever sees `pm_xxx` tokens, which are not card data and are not PCI in-scope. ## What you’re responsible for * **Secure your secret keys.** Never expose `sk_...` keys in client-side code or source control. * **Use HTTPS.** All pages that load the payment widget must be served over HTTPS. * **Verify webhook signatures.** Authenticate incoming webhook events using the `X-Webhook-Signature` header. See [Webhooks](/api/guides/webhooks). * **Access control.** Restrict dashboard access and API key visibility to only the people who need it. ## What ElasticPay handles * Card number tokenization via our PCI Token Vault * Encryption of card data in transit and at rest * PCI DSS Level 1 audit and certification * Key rotation for cryptographic keys * Fraud detection at the PSP layer ## Direct API integration If you send raw card numbers directly to the API (not via the widget), your server handles card data and your integration becomes in-scope for PCI DSS. You will need to complete your own PCI assessment. Caution Always prefer the widget or hosted checkout to keep your integration out of PCI scope. Only submit raw card data via the API if you have a specific need and the infrastructure to support PCI compliance.
# Status Reference
> Full reference for all payment and intent status values.
## Payment intent statuses | Status | Description | Terminal? | | ------------------------- | ----------------------------------------- | --------- | | `requires_payment_method` | No payment method attached | No | | `requires_confirmation` | Payment method attached, awaiting confirm | No | | `requires_action` | Customer action required (e.g. 3DS) | No | | `processing` | Submitted to PSP, awaiting result | No | | `succeeded` | Payment completed | Yes | | `refunded` | All captured funds have been refunded | Yes | | `failed` | Payment declined or errored | Yes | | `canceled` | Explicitly canceled | Yes | See [Payment Lifecycle](/api/concepts/payment-lifecycle) for the transition rules between these states. ## Refund statuses Refunds are separate objects linked to a payment intent. | Status | Description | | ----------- | --------------------------------------- | | `succeeded` | Refund confirmed by PSP | | `pending` | Submitted to PSP, awaiting confirmation | | `failed` | PSP rejected the refund | A failed refund does not affect the parent payment intent’s status. To retry, issue a new refund request. ## Setup intent statuses Setup intents are used to save a payment method for future use. | Status | Description | Terminal? | | ------------------------- | --------------------------------- | --------- | | `requires_payment_method` | No payment method collected | No | | `processing` | Submitted to PSP for verification | No | | `succeeded` | Payment method saved | Yes | | `failed` | Setup failed | Yes | | `canceled` | Explicitly canceled | Yes | ## Payment methods Payment methods (`pm_xxx`) do not have a status field. A payment method either exists (and is usable) or has been deleted. The `used` flag on the tokenization result indicates whether a single-use token has already been consumed. ## Test clock statuses Sandbox-only — see [Test Clocks](/api/guides/test-clocks). | Status | Description | Terminal? | | ----------- | ---------------------------------------------------- | --------- | | `created` | Clock created, never advanced | No | | `advancing` | An advance is in progress | No | | `ready` | Last advance completed; clock can advance again | No | | `failed` | An advance failed — delete the clock and recreate it | Yes |
# Stored Credentials & Recurring Charges
> How ElasticPay makes saved-method and recurring charges scheme-compliant.
## Two kinds of transaction Card schemes and direct debit rules distinguish **who initiated** a charge: * **Customer-initiated (CIT)** — the customer is present and actively paying: a checkout, a one-off payment, the moment they save their details. * **Merchant-initiated (MIT)** — you charge a stored method later, under a prior agreement: subscription renewals, usage bills, payment plan instalments, repeat phone orders. The rules require that an MIT be traceable back to a properly established agreement. Done right, merchant-initiated charges are approved more often, disputed less, and processed at the best available scheme treatment. Done wrong, they degrade silently — more declines, weaker dispute standing. ## What “properly established” means **For cards**, the stored credential must be created during a real, customer-present transaction — either a verification (Setup Intent) or the customer’s first purchase. That founding transaction produces a **network transaction reference** that every later merchant-initiated charge must cite, along with stored-credential flags identifying the charge as an MIT under an existing agreement. **For BECS**, the equivalent is the [direct debit mandate](/api/payment-methods/becs-direct-debit#the-mandate): electronic acceptance of a Direct Debit Request, with evidence retained. ## What ElasticPay does automatically When you use the standard flows — [Setup Intent](/api/use-cases/save-a-payment-method) or the store-on-first-purchase flag — ElasticPay: * establishes the credential with a genuine verification or purchase transaction (never a bare “store this number”) * captures the network transaction reference from that founding transaction * flags every later charge against that `pm_xxx` correctly as customer-initiated or merchant-initiated, and threads the original reference through each MIT * generates, stores, and retains the BECS mandate and its acceptance evidence None of this appears in your API calls. It’s why the saved-method flows are the *only* supported way to store a credential. ## What you’re responsible for ElasticPay can prove *how* a credential was stored; only you can prove *what the customer agreed to*. Before charging a stored method: * capture the customer’s agreement to the charges you’ll make — amount or amount basis, frequency or trigger, and how to cancel * keep that agreement; it’s your evidence in a dispute * charge only within its terms, and stop when the customer cancels * attach saved methods to a `customer_id` so every charge is traceable to the person who consented ## Where this shows up in the docs | Flow | Guide | | --------------------------- | ----------------------------------------------------------------------------- | | Establishing the credential | [Save a Payment Method](/api/use-cases/save-a-payment-method) | | One-off MIT | [Charge a Saved Payment Method](/api/use-cases/charge-a-saved-payment-method) | | Your own billing schedule | [Recurring Without a Plan](/api/use-cases/recurring-without-a-plan) | | Platform-managed billing | [Recurring With a Payment Plan](/api/use-cases/recurring-with-a-payment-plan) | | Single future-dated charge | [Scheduled Payments](/api/guides/scheduled-payments) |
# What Test Clocks Simulate
> Exactly which billing behaviours move with a test clock — and which don't.
[Test clocks](/api/guides/test-clocks) compress time for billing logic, but not everything in a payment system is governed by billing logic. This page is the definitive list of what moves with the clock and what stays on real time. ## What the clock animates | Billing behaviour | Clock-animated? | | ------------------------------------------------- | -------------------------------------------- | | Payment plan scheduling (next due dates) | ✅ Yes | | Payment dispatch for card plans and subscriptions | ✅ Yes — card collection completes in sandbox | | Subscription period rollover | ✅ Yes | | Payment-link expiry | ✅ Yes | | Dunning / retry schedules | ✅ Yes | | Plan failure behaviour (retry dates, catch-up) | ✅ Yes | **Card is the fully animated payment method**: when the clock dispatches a card payment, the sandbox processes it to a final outcome immediately, so a single advance takes a card plan through dispatch *and* result. ## What the clock does not animate | Behaviour | Why | | -------------------------------- | ---------------------------------------------------------------------------------------------- | | BECS collection outcomes | Bank debits are inherently multi-day and externally governed — see below | | Settlement, holds, payouts | Governed by real time and banking calendars; compressing them would misrepresent real cashflow | | 3D Secure challenges | Require customer interaction; time alone can’t complete them | | Refund timing | Refunds aren’t time-scheduled | | Sandbox funds becoming spendable | Balances show in-transit and lodged amounts, but never advance to spendable in sandbox | | Audit timestamps | `created_at` and similar always record real wall-clock time | ## The BECS carve-out If a clock-bound customer has a BECS payment plan or subscription, the clock **schedules and dispatches** those payments at simulated time — the scheduling side is fully clock-aware. But the **collection outcome** (success, dishonour, retry) does not fire from the advance: the payment stays in its post-dispatch state until the normal sandbox BECS pathway processes it on real time. Practical implication: use a test clock to verify *when* BECS payments fire and how schedules behave; use the normal [sandbox flow](/api/getting-started/test-mode) to verify BECS *outcomes*. Card plans can verify both in one advance. ## Timestamps: simulated vs real Two kinds of time appear on clock-bound objects: * **Scheduling fields** (`frozen_time`, next payment dates, period starts and ends, link expiry) — simulated time * **Audit fields** (`created_at`, `last_advanced_at`) — real wall-clock time A payment intent generated at simulated Feb 1 will show a real-world `created_at` of whenever you ran the advance. This is expected.
# Test vs Live Mode
> Understand the difference between test and live environments.
## The one rule The key you use determines the mode. No other flag, header, or environment variable is needed: * `sk_sandbox_...` / `pk_sandbox_...` → test mode * `sk_live_...` / `pk_live_...` → live mode ## What’s different in test mode | Aspect | Test mode | Live mode | | -------------- | ------------------------------------------------------------ | ----------------------- | | PSP calls | Mocked — no real processing | Real PSP calls | | Cards accepted | Test card numbers only | Real cards | | Data isolation | Separate from live data | Separate from test data | | Webhooks | Fire with test events | Fire with live events | | Time | Can be simulated with [test clocks](/api/guides/test-clocks) | Real time only | | API responses | Identical shape | Identical shape | ## What’s the same * Same API endpoints * Same request and response structure * Same webhook event format and delivery * Same error codes and response shapes * Same status transitions ## Switching to live Sandbox and live are two paired accounts, not two modes of one account. Getting a live account and getting it activated is the bulk of the work: 1. **Create the live account.** In the dashboard, go to **Settings → Manage Accounts** and use **Promote to Live** on your sandbox account. This creates the paired live account 2. **Complete onboarding.** The new live account’s dashboard shows a **Continue onboarding** banner. It opens the onboarding application — business details, beneficial owners, supporting documents, and terms acceptance 3. **Wait for the review decision.** The onboarding application shows the current status once submitted, including when the review team needs more from you. Approval activates the account and the banner disappears 4. **Swap keys.** Replace `sk_sandbox_` with `sk_live_` in your server environment and `pk_sandbox_` with `pk_live_` in your client-side code, using the keys from the live account under **Developer → API keys** 5. **Register your production webhook endpoint.** Ask support to add it against the live account — sandbox subscriptions do not carry over Caution A live account that has not been activated rejects payment requests with a `live_not_ready` error. Wait until the **Continue onboarding** banner is gone before cutting production traffic over. ## Keeping environments separate * Store sandbox keys in `.env.development` and live keys in `.env.production` * Never mix test and live keys in the same application instance * Use separate webhook endpoints for test and live events — each account has its own subscriptions, registered by support * Test data is never visible in live mode and vice versa
# Customers
> Create and manage customers with the ElasticPay API.
## Overview A **Customer** is a reusable entity that groups a payer’s saved payment methods, payment history, and payment plans under a single identifier. Attaching customers to payment intents lets you: * Charge saved payment methods without the customer present * Link transactions to a payer for reporting and reconciliation * Set up recurring payment plans via the API All customer operations require a **secret key** (`sk_xxx`). ## Create a customer * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/customers \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "name": "Alice Smith", "email": "alice@example.com", "reference": "alice-user-42" }' ``` * Node.js ```js const res = await fetch("https://api.elasticpay.co/api/v1/customers", { method: "POST", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ name: "Alice Smith", email: "alice@example.com", reference: "alice-user-42", }), }); const customer = await res.json(); ``` * Python ```python import requests res = requests.post( "https://api.elasticpay.co/api/v1/customers", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={"name": "Alice Smith", "email": "alice@example.com", "reference": "alice-user-42"}, ) customer = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->post('/api/v1/customers', [ 'json' => [ 'name' => 'Alice Smith', 'email' => 'alice@example.com', 'reference' => 'alice-user-42', ], ]); $customer = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.post('/api/v1/customers') do |req| req.body = { name: 'Alice Smith', email: 'alice@example.com', reference: 'alice-user-42' } end customer = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var customer = await http.PostAsJsonAsync("/api/v1/customers", new { name = "Alice Smith", email = "alice@example.com", reference = "alice-user-42", }); var result = await customer.Content.ReadFromJsonAsync(); ``` **Response** ```json { "id": "cus_0abc123def456ghi789jkl012mno", "name": "Alice Smith", "email": "alice@example.com", "reference": "alice-user-42", "created_at": "2026-04-01T10:00:00Z", "updated_at": "2026-04-01T10:00:00Z" } ``` The `id` (e.g. `cus_0abc...`) is the customer’s stable identifier to use in subsequent requests. The `reference` field is a freeform string you can use to correlate the customer with a record in your own system. In sandbox, you can also pass `test_clock: "clk_..."` to attach the customer to a [test clock](/api/guides/test-clocks) at creation. The field is immutable, and clock-bound customers are excluded from the default customer list. ## Fetch a customer * cURL ```bash curl https://api.elasticpay.co/api/v1/customers/cus_0abc123def456ghi789jkl012mno \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` * Node.js ```js const res = await fetch( "https://api.elasticpay.co/api/v1/customers/cus_0abc123def456ghi789jkl012mno", { headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } } ); const customer = await res.json(); ``` * Python ```python import requests res = requests.get( "https://api.elasticpay.co/api/v1/customers/cus_0abc123def456ghi789jkl012mno", headers={"Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, ) customer = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->get('/api/v1/customers/cus_0abc123def456ghi789jkl012mno'); $customer = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.get('/api/v1/customers/cus_0abc123def456ghi789jkl012mno') customer = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var customer = await http.GetFromJsonAsync( "/api/v1/customers/cus_0abc123def456ghi789jkl012mno"); ``` ## List customers * cURL ```bash curl "https://api.elasticpay.co/api/v1/customers?limit=20" \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` * Node.js ```js const res = await fetch( "https://api.elasticpay.co/api/v1/customers?limit=20", { headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } } ); const { data, has_more } = await res.json(); ``` * Python ```python import requests res = requests.get( "https://api.elasticpay.co/api/v1/customers", headers={"Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, params={"limit": 20}, ) data = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->get('/api/v1/customers', ['query' => ['limit' => 20]]); $data = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.get('/api/v1/customers') { |req| req.params['limit'] = 20 } data = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var data = await http.GetFromJsonAsync( "/api/v1/customers?limit=20"); ``` **Response shape** ```json { "data": [], "has_more": false } ``` Supported query parameters: | Parameter | Description | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `limit` | Number of results per page (default 20, max 100) | | `starting_after` | Cursor: return results after this customer ID | | `email` | Filter by email (exact match) | | `reference` | Filter by your external reference | | `test_clock` | Sandbox only: list customers attached to this [test clock](/api/guides/test-clocks). Without it, clock-bound customers are excluded | ## Update a customer Use `PUT` to update any mutable field. Fields not included in the request body are left unchanged. * cURL ```bash curl -X PUT https://api.elasticpay.co/api/v1/customers/cus_0abc123def456ghi789jkl012mno \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"email": "new-email@example.com"}' ``` * Node.js ```js const res = await fetch( "https://api.elasticpay.co/api/v1/customers/cus_0abc123def456ghi789jkl012mno", { method: "PUT", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ email: "new-email@example.com" }), } ); const updated = await res.json(); ``` * Python ```python import requests res = requests.put( "https://api.elasticpay.co/api/v1/customers/cus_0abc123def456ghi789jkl012mno", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={"email": "new-email@example.com"}, ) updated = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->put('/api/v1/customers/cus_0abc123def456ghi789jkl012mno', [ 'json' => ['email' => 'new-email@example.com'], ]); $updated = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.put('/api/v1/customers/cus_0abc123def456ghi789jkl012mno') do |req| req.body = { email: 'new-email@example.com' } end updated = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var res = await http.PutAsJsonAsync( "/api/v1/customers/cus_0abc123def456ghi789jkl012mno", new { email = "new-email@example.com" }); var updated = await res.Content.ReadFromJsonAsync(); ``` ## Delete a customer Deleting a customer performs a **soft-delete** — the record is hidden from API responses and the portal but retained in the database for audit purposes. Payment intents and transactions associated with the customer are preserved. * cURL ```bash curl -X DELETE https://api.elasticpay.co/api/v1/customers/cus_0abc123def456ghi789jkl012mno \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` * Node.js ```js await fetch( "https://api.elasticpay.co/api/v1/customers/cus_0abc123def456ghi789jkl012mno", { method: "DELETE", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }, } ); ``` * Python ```python import requests requests.delete( "https://api.elasticpay.co/api/v1/customers/cus_0abc123def456ghi789jkl012mno", headers={"Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, ) ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $client->delete('/api/v1/customers/cus_0abc123def456ghi789jkl012mno'); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' conn.delete('/api/v1/customers/cus_0abc123def456ghi789jkl012mno') ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); await http.DeleteAsync("/api/v1/customers/cus_0abc123def456ghi789jkl012mno"); ``` A successful deletion returns `204 No Content`. ## List a customer’s payment methods Retrieve all saved payment methods (`pm_xxx`) for a customer: * cURL ```bash curl https://api.elasticpay.co/api/v1/customers/cus_0abc123def456ghi789jkl012mno/payment_methods \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` * Node.js ```js const res = await fetch( "https://api.elasticpay.co/api/v1/customers/cus_0abc123def456ghi789jkl012mno/payment_methods", { headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } } ); const { data } = await res.json(); ``` * Python ```python import requests res = requests.get( "https://api.elasticpay.co/api/v1/customers/cus_0abc123def456ghi789jkl012mno/payment_methods", headers={"Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, ) data = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->get('/api/v1/customers/cus_0abc123def456ghi789jkl012mno/payment_methods'); $data = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.get('/api/v1/customers/cus_0abc123def456ghi789jkl012mno/payment_methods') data = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var data = await http.GetFromJsonAsync( "/api/v1/customers/cus_0abc123def456ghi789jkl012mno/payment_methods"); ``` ## Attach a customer to a payment intent Pass `customer_id` when creating a payment intent to associate it with a customer: * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "amount": 4900, "currency": "AUD", "customer_id": "cus_0abc123def456ghi789jkl012mno" }' ``` * Node.js ```js const res = await fetch("https://api.elasticpay.co/api/v1/payment_intents", { method: "POST", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ amount: 4900, currency: "AUD", customer_id: "cus_0abc123def456ghi789jkl012mno", }), }); const pi = await res.json(); ``` * Python ```python import requests res = requests.post( "https://api.elasticpay.co/api/v1/payment_intents", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={"amount": 4900, "currency": "AUD", "customer_id": "cus_0abc123def456ghi789jkl012mno"}, ) pi = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->post('/api/v1/payment_intents', [ 'json' => [ 'amount' => 4900, 'currency' => 'AUD', 'customer_id' => 'cus_0abc123def456ghi789jkl012mno', ], ]); $pi = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.post('/api/v1/payment_intents') do |req| req.body = { amount: 4900, currency: 'AUD', customer_id: 'cus_0abc123def456ghi789jkl012mno' } end pi = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var res = await http.PostAsJsonAsync("/api/v1/payment_intents", new { amount = 4900, currency = "AUD", customer_id = "cus_0abc123def456ghi789jkl012mno", }); var pi = await res.Content.ReadFromJsonAsync(); ``` ## Required permissions | Operation | Required scope | | ------------------------ | ----------------- | | Create / update / delete | `customers:write` | | Fetch / list | `customers:read` | Secret keys (`sk_xxx`) have both scopes by default.
# Direct API Integration
> Integrate directly with the ElasticPay API for full control.
## When to use direct integration Direct API integration gives you full control over the payment UI — useful for native mobile apps or highly custom checkout flows. If you handle raw card numbers, your integration becomes in-scope for PCI DSS. See [Security & PCI Compliance](/api/concepts/security-pci) for details. For most web integrations, the [widget](/api/widget/embedding) handles card data and keeps you out of PCI scope. ## Create and confirm in sequence Use a tokenised `pm_xxx` ID to create and immediately confirm a payment intent server-side: * cURL ```bash # 1. Create payment intent curl -X POST https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"amount": 3500, "currency": "AUD"}' # 2. Confirm with tokenised payment method curl -X POST https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123/confirm \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"payment_method": "pm_0xyz789abc123def456ghi012jkl"}' ``` * Node.js ```js const BASE = "https://api.elasticpay.co"; const AUTH = "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; // 1. Create payment intent const createRes = await fetch(`${BASE}/api/v1/payment_intents`, { method: "POST", headers: { "Authorization": AUTH, "Content-Type": "application/json" }, body: JSON.stringify({ amount: 3500, currency: "AUD" }), }); const { id } = await createRes.json(); // 2. Confirm with tokenised payment method const confirmRes = await fetch( `${BASE}/api/v1/payment_intents/${id}/confirm`, { method: "POST", headers: { "Authorization": AUTH, "Content-Type": "application/json" }, body: JSON.stringify({ payment_method: "pm_0xyz789abc123def456ghi012jkl" }), } ); const result = await confirmRes.json(); ``` * Python ```python import requests BASE = "https://api.elasticpay.co" HEADERS = { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", } # 1. Create payment intent pi = requests.post( f"{BASE}/api/v1/payment_intents", headers=HEADERS, json={"amount": 3500, "currency": "AUD"}, ).json() # 2. Confirm with tokenised payment method result = requests.post( f"{BASE}/api/v1/payment_intents/{pi['id']}/confirm", headers=HEADERS, json={"payment_method": "pm_0xyz789abc123def456ghi012jkl"}, ).json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); // 1. Create payment intent $createRes = $client->post('/api/v1/payment_intents', [ 'json' => ['amount' => 3500, 'currency' => 'AUD'], ]); $pi = json_decode($createRes->getBody(), true); // 2. Confirm with tokenised payment method $confirmRes = $client->post('/api/v1/payment_intents/' . $pi['id'] . '/confirm', [ 'json' => ['payment_method' => 'pm_0xyz789abc123def456ghi012jkl'], ]); $result = json_decode($confirmRes->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' # 1. Create payment intent create_res = conn.post('/api/v1/payment_intents') do |req| req.body = { amount: 3500, currency: 'AUD' } end pi_id = create_res.body['id'] # 2. Confirm with tokenised payment method result = conn.post("/api/v1/payment_intents/#{pi_id}/confirm") do |req| req.body = { payment_method: 'pm_0xyz789abc123def456ghi012jkl' } end result = result.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); // 1. Create payment intent var createRes = await http.PostAsJsonAsync("/api/v1/payment_intents", new { amount = 3500, currency = "AUD" }); var pi = await createRes.Content.ReadFromJsonAsync(); var piId = pi.GetProperty("id").GetString(); // 2. Confirm with tokenised payment method var confirmRes = await http.PostAsJsonAsync( $"/api/v1/payment_intents/{piId}/confirm", new { payment_method = "pm_0xyz789abc123def456ghi012jkl" }); var result = await confirmRes.Content.ReadFromJsonAsync(); ``` ## Idempotency Add an `Idempotency-Key` header to safely retry requests without risk of double-charging: * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order_9a8b7c6d-unique-key-here" \ -d '{"amount": 3500, "currency": "AUD"}' ``` * Node.js ```js async function createPaymentWithRetry(amount, idempotencyKey) { const response = await fetch( "https://api.elasticpay.co/api/v1/payment_intents", { method: "POST", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ amount, currency: "AUD" }), } ); if (!response.ok) { const error = await response.json(); throw new Error(error.error?.message ?? "Request failed"); } return response.json(); } ``` * Python ```python import requests import uuid def create_payment_with_retry(amount, idempotency_key): res = requests.post( "https://api.elasticpay.co/api/v1/payment_intents", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", "Idempotency-Key": idempotency_key, }, json={"amount": amount, "currency": "AUD"}, ) res.raise_for_status() return res.json() # Generate once; reuse on retry key = str(uuid.uuid4()) payment = create_payment_with_retry(3500, key) ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); function createPaymentWithRetry($client, int $amount, string $idempotencyKey): array { $res = $client->post('/api/v1/payment_intents', [ 'headers' => ['Idempotency-Key' => $idempotencyKey], 'json' => ['amount' => $amount, 'currency' => 'AUD'], ]); return json_decode($res->getBody(), true); } $key = uniqid('order_', true); $payment = createPaymentWithRetry($client, 3500, $key); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' def create_payment_with_retry(conn, amount, idempotency_key) res = conn.post('/api/v1/payment_intents') do |req| req.headers['Idempotency-Key'] = idempotency_key req.body = { amount: amount, currency: 'AUD' } end res.body end key = SecureRandom.uuid payment = create_payment_with_retry(conn, 3500, key) ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); async Task CreatePaymentWithRetry(int amount, string idempotencyKey) { var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/payment_intents") { Content = JsonContent.Create(new { amount, currency = "AUD" }), }; req.Headers.Add("Idempotency-Key", idempotencyKey); var res = await http.SendAsync(req); res.EnsureSuccessStatusCode(); return await res.Content.ReadFromJsonAsync(); } var key = Guid.NewGuid().ToString(); var payment = await CreatePaymentWithRetry(3500, key); ``` - The same key returns the same response within the TTL window - Default TTL: 900 seconds; maximum: 1800 seconds - Keys are scoped per biller account - Use a UUID or a unique order/transaction identifier as the key
# Payment Plans API
> Create and manage recurring payment plans via the ElasticPay API.
## Overview The Payment Plans API lets you programmatically create, manage, and control recurring billing schedules. A payment plan generates and processes payment intents automatically on your chosen frequency. For background on how plans work, see [Understanding Plans](/portal/payment-plans/understanding-plans). All payment plan operations require a **secret key** (`sk_xxx`) with `payment_plans:read` or `payment_plans:write` scope. ## Create a payment plan * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_plans \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "cus_0abc123def456ghi789jkl012mno", "name": "Monthly gym membership", "frequency_type": "monthly", "recurring_amount_cents": 4900, "currency": "AUD", "start_date": "2026-05-01", "until_further_notice": true }' ``` * Node.js ```js const res = await fetch("https://api.elasticpay.co/api/v1/payment_plans", { method: "POST", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ customer_id: "cus_0abc123def456ghi789jkl012mno", name: "Monthly gym membership", frequency_type: "monthly", recurring_amount_cents: 4900, currency: "AUD", start_date: "2026-05-01", until_further_notice: true, }), }); const plan = await res.json(); ``` * Python ```python import requests res = requests.post( "https://api.elasticpay.co/api/v1/payment_plans", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={ "customer_id": "cus_0abc123def456ghi789jkl012mno", "name": "Monthly gym membership", "frequency_type": "monthly", "recurring_amount_cents": 4900, "currency": "AUD", "start_date": "2026-05-01", "until_further_notice": True, }, ) plan = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->post('/api/v1/payment_plans', [ 'json' => [ 'customer_id' => 'cus_0abc123def456ghi789jkl012mno', 'name' => 'Monthly gym membership', 'frequency_type' => 'monthly', 'recurring_amount_cents' => 4900, 'currency' => 'AUD', 'start_date' => '2026-05-01', 'until_further_notice' => true, ], ]); $plan = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.post('/api/v1/payment_plans') do |req| req.body = { customer_id: 'cus_0abc123def456ghi789jkl012mno', name: 'Monthly gym membership', frequency_type: 'monthly', recurring_amount_cents: 4900, currency: 'AUD', start_date: '2026-05-01', until_further_notice: true } end plan = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var res = await http.PostAsJsonAsync("/api/v1/payment_plans", new { customer_id = "cus_0abc123def456ghi789jkl012mno", name = "Monthly gym membership", frequency_type = "monthly", recurring_amount_cents = 4900, currency = "AUD", start_date = "2026-05-01", until_further_notice = true, }); var plan = await res.Content.ReadFromJsonAsync(); ``` **Response** ```json { "id": "pp_0abc123def456ghi789jkl012mno", "customer_id": "cus_0abc123def456ghi789jkl012mno", "name": "Monthly gym membership", "state": "pending", "frequency_type": "monthly", "frequency_period": 1, "recurring_amount_cents": 4900, "currency": "AUD", "start_date": "2026-05-01", "until_further_notice": true, "created_at": "2026-04-01T10:00:00Z", "updated_at": "2026-04-01T10:00:00Z" } ``` ### Create parameters | Parameter | Type | Required | Description | | ------------------------ | ------- | -------- | -------------------------------------------------------------------------------------------------------------- | | `customer_id` | string | Yes | Customer (`cus_xxx`) who will be charged | | `name` | string | Yes | Human-readable label | | `frequency_type` | string | Yes | `weekly` or `monthly` | | `frequency_period` | integer | No | Interval multiplier (default 1). `2` + `weekly` = fortnightly | | `recurring_amount_cents` | integer | Yes | Amount per scheduled payment in the smallest currency unit | | `first_amount_cents` | integer | No | Different amount for the first payment only | | `total_amount_cents` | integer | No | Cap total collections; plan closes when reached | | `until_further_notice` | boolean | No | `true` for open-ended plans with no total cap | | `currency` | string | Yes | ISO 4217 currency code (e.g. `AUD`, `USD`) | | `start_date` | string | Yes | ISO date (`YYYY-MM-DD`) of the first payment | | `first_date` | string | No | Override the first payment date (defaults to `start_date`) | | `payment_instrument_id` | string | No | Saved payment method (`pm_xxx`) to use. Falls back to customer default | | `failure_behaviour` | string | No | Failure handling mode (e.g. `stop`, `retry`). See [Handling Failures](/portal/payment-plans/handling-failures) | Either `total_amount_cents` or `until_further_notice: true` must be set. ## Fetch a payment plan * cURL ```bash curl https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` * Node.js ```js const res = await fetch( "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno", { headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } } ); const plan = await res.json(); ``` * Python ```python import requests res = requests.get( "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno", headers={"Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, ) plan = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->get('/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno'); $plan = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.get('/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno') plan = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var plan = await http.GetFromJsonAsync( "/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno"); ``` ## List payment plans * cURL ```bash curl "https://api.elasticpay.co/api/v1/payment_plans?customer_id=cus_0abc123def456" \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` * Node.js ```js const res = await fetch( "https://api.elasticpay.co/api/v1/payment_plans?customer_id=cus_0abc123def456", { headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } } ); const { data, has_more } = await res.json(); ``` * Python ```python import requests res = requests.get( "https://api.elasticpay.co/api/v1/payment_plans", headers={"Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, params={"customer_id": "cus_0abc123def456"}, ) data = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->get('/api/v1/payment_plans', [ 'query' => ['customer_id' => 'cus_0abc123def456'], ]); $data = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.get('/api/v1/payment_plans') do |req| req.params['customer_id'] = 'cus_0abc123def456' end data = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var data = await http.GetFromJsonAsync( "/api/v1/payment_plans?customer_id=cus_0abc123def456"); ``` **Response shape** ```json { "data": [], "has_more": false } ``` Supported query parameters: | Parameter | Description | | ---------------- | --------------------------------------------------------------------------- | | `customer_id` | Filter plans for a specific customer | | `state` | Filter by state (`pending`, `active`, `suspended`, `deactivated`, `closed`) | | `limit` | Number of results per page (default 20, max 100) | | `starting_after` | Cursor: return results after this plan ID | ## Update a payment plan Use `PUT` to update mutable fields on a plan. Not all fields can be changed after the plan is active (e.g. `frequency_type`, `start_date`). The API will return a validation error if a field cannot be changed in the plan’s current state. * cURL ```bash curl -X PUT https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"recurring_amount_cents": 5900}' ``` * Node.js ```js const res = await fetch( "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno", { method: "PUT", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ recurring_amount_cents: 5900 }), } ); const updated = await res.json(); ``` * Python ```python import requests res = requests.put( "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={"recurring_amount_cents": 5900}, ) updated = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->put('/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno', [ 'json' => ['recurring_amount_cents' => 5900], ]); $updated = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.put('/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno') do |req| req.body = { recurring_amount_cents: 5900 } end updated = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var res = await http.PutAsJsonAsync( "/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno", new { recurring_amount_cents = 5900 }); var updated = await res.Content.ReadFromJsonAsync(); ``` ## State transitions Payment plans move through states via dedicated transition endpoints. * cURL ```bash # Activate (pending -> active) curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/activate \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # Suspend (active -> suspended) curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/suspend \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # Resume (suspended -> active) curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/resume \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # Deactivate (active -> deactivated) curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/deactivate \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # Close (any -> closed) curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/close \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` * Node.js ```js const AUTH = "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; const BASE = "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123"; async function transition(action) { const res = await fetch(`${BASE}/${action}`, { method: "POST", headers: { "Authorization": AUTH }, }); return res.json(); } await transition("activate"); // pending -> active await transition("suspend"); // active -> suspended await transition("resume"); // suspended -> active await transition("deactivate"); // active -> deactivated await transition("close"); // any -> closed ``` * Python ```python import requests AUTH = {"Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} BASE = "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123" def transition(action): return requests.post(f"{BASE}/{action}", headers=AUTH).json() transition("activate") # pending -> active transition("suspend") # active -> suspended transition("resume") # suspended -> active transition("deactivate") # active -> deactivated transition("close") # any -> closed ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $planId = 'pp_0abc123'; foreach (['activate', 'suspend', 'resume', 'deactivate', 'close'] as $action) { $client->post("/api/v1/payment_plans/{$planId}/{$action}"); } ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' plan_id = 'pp_0abc123' %w[activate suspend resume deactivate close].each do |action| conn.post("/api/v1/payment_plans/#{plan_id}/#{action}") end ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); const string planId = "pp_0abc123"; foreach (var action in new[] { "activate", "suspend", "resume", "deactivate", "close" }) { await http.PostAsync($"/api/v1/payment_plans/{planId}/{action}", null); } ``` ### Valid transitions | From | To | Endpoint | | -------------------------------- | ------------- | ------------- | | `pending` | `active` | `/activate` | | `active` | `suspended` | `/suspend` | | `active` | `deactivated` | `/deactivate` | | `suspended` | `active` | `/resume` | | `active`, `suspended`, `pending` | `closed` | `/close` | ## Webhook events Subscribe to these events to monitor plan activity: | Event | When | | -------------------------- | -------------------------------- | | `payment_plan.activated` | Plan transitions to active | | `payment_plan.suspended` | Plan is suspended | | `payment_plan.deactivated` | Plan ends | | `payment_plan.closed` | Plan is closed | | `payment_intent.succeeded` | A scheduled payment is collected | | `payment_intent.failed` | A scheduled payment fails | ## Required permissions | Operation | Required scope | | ----------------------------------- | --------------------- | | Create / update / state transitions | `payment_plans:write` | | Fetch / list | `payment_plans:read` | Secret keys (`sk_xxx`) have both scopes by default.
# Recurring Payments
> Set up recurring payments and subscription billing.
## Overview Recurring payments require two steps: 1. **Save a payment method** — collect card details once and store a `pm_xxx` token on the customer 2. **Charge the saved method** — create and confirm payment intents using the stored token, without the customer present ## Save a payment method (Setup Intent) A setup intent guides the customer through saving their payment method. Create one server-side, pass the `client_secret` to the widget, and the widget handles card collection. * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/setup_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"customer_id": "cus_0abc123def456"}' ``` * Node.js ```js const res = await fetch("https://api.elasticpay.co/api/v1/setup_intents", { method: "POST", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ customer_id: "cus_0abc123def456" }), }); const { client_secret } = await res.json(); ``` * Python ```python import requests res = requests.post( "https://api.elasticpay.co/api/v1/setup_intents", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={"customer_id": "cus_0abc123def456"}, ) client_secret = res.json()["client_secret"] ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->post('/api/v1/setup_intents', [ 'json' => ['customer_id' => 'cus_0abc123def456'], ]); $data = json_decode($res->getBody(), true); $client_secret = $data['client_secret']; ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.post('/api/v1/setup_intents') do |req| req.body = { customer_id: 'cus_0abc123def456' } end client_secret = res.body['client_secret'] ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var res = await http.PostAsJsonAsync("/api/v1/setup_intents", new { customer_id = "cus_0abc123def456" }); var si = await res.Content.ReadFromJsonAsync(); var clientSecret = si.GetProperty("client_secret").GetString(); ``` After the customer completes the widget flow, the `setup_intent.succeeded` event fires and the saved `pm_xxx` is available on the customer record. ## Charge a saved payment method Create a payment intent and confirm it server-side using the saved `pm_xxx`. No customer interaction required: * cURL ```bash # 1. Create payment intent curl -X POST https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"amount": 4900, "currency": "AUD", "customer_id": "cus_0abc123def456"}' # 2. Confirm with saved payment method curl -X POST https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123/confirm \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"payment_method": "pm_0xyz789abc123def456ghi012jkl"}' ``` * Node.js ```js // 1. Create payment intent const createRes = await fetch("https://api.elasticpay.co/api/v1/payment_intents", { method: "POST", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ amount: 4900, currency: "AUD", customer_id: "cus_0abc123def456" }), }); const { id } = await createRes.json(); // 2. Confirm with saved payment method const confirmRes = await fetch( `https://api.elasticpay.co/api/v1/payment_intents/${id}/confirm`, { method: "POST", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ payment_method: "pm_0xyz789abc123def456ghi012jkl" }), } ); const result = await confirmRes.json(); ``` * Python ```python import requests BASE = "https://api.elasticpay.co" HEADERS = { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", } # 1. Create payment intent pi = requests.post( f"{BASE}/api/v1/payment_intents", headers=HEADERS, json={"amount": 4900, "currency": "AUD", "customer_id": "cus_0abc123def456"}, ).json() # 2. Confirm with saved payment method result = requests.post( f"{BASE}/api/v1/payment_intents/{pi['id']}/confirm", headers=HEADERS, json={"payment_method": "pm_0xyz789abc123def456ghi012jkl"}, ).json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); // 1. Create payment intent $createRes = $client->post('/api/v1/payment_intents', [ 'json' => ['amount' => 4900, 'currency' => 'AUD', 'customer_id' => 'cus_0abc123def456'], ]); $pi = json_decode($createRes->getBody(), true); // 2. Confirm with saved payment method $confirmRes = $client->post('/api/v1/payment_intents/' . $pi['id'] . '/confirm', [ 'json' => ['payment_method' => 'pm_0xyz789abc123def456ghi012jkl'], ]); $result = json_decode($confirmRes->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' # 1. Create payment intent create_res = conn.post('/api/v1/payment_intents') do |req| req.body = { amount: 4900, currency: 'AUD', customer_id: 'cus_0abc123def456' } end pi_id = create_res.body['id'] # 2. Confirm with saved payment method result = conn.post("/api/v1/payment_intents/#{pi_id}/confirm") do |req| req.body = { payment_method: 'pm_0xyz789abc123def456ghi012jkl' } end result = result.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); // 1. Create payment intent var createRes = await http.PostAsJsonAsync("/api/v1/payment_intents", new { amount = 4900, currency = "AUD", customer_id = "cus_0abc123def456" }); var pi = await createRes.Content.ReadFromJsonAsync(); var piId = pi.GetProperty("id").GetString(); // 2. Confirm with saved payment method var confirmRes = await http.PostAsJsonAsync( $"/api/v1/payment_intents/{piId}/confirm", new { payment_method = "pm_0xyz789abc123def456ghi012jkl" }); var result = await confirmRes.Content.ReadFromJsonAsync(); ``` ## List saved payment methods Retrieve all saved payment methods for a customer: * cURL ```bash curl https://api.elasticpay.co/api/v1/customers/cus_0abc123def456/payment_methods \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` * Node.js ```js const res = await fetch( "https://api.elasticpay.co/api/v1/customers/cus_0abc123def456/payment_methods", { headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } } ); const { data } = await res.json(); ``` * Python ```python import requests res = requests.get( "https://api.elasticpay.co/api/v1/customers/cus_0abc123def456/payment_methods", headers={"Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, ) data = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->get('/api/v1/customers/cus_0abc123def456/payment_methods'); $data = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.get('/api/v1/customers/cus_0abc123def456/payment_methods') data = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var data = await http.GetFromJsonAsync( "/api/v1/customers/cus_0abc123def456/payment_methods"); ``` ## Managing payment plans For scheduled recurring payments (weekly, monthly, etc.), use the portal’s Payment Plans feature instead of scheduling intents manually. The portal handles scheduling, failure recovery, and retry logic. See [Understanding Plans](/portal/payment-plans/understanding-plans) for details.
# Refunds
> Issue full or partial refunds through the ElasticPay API.
## Full refund To refund the full payment amount, POST to the refunds endpoint with no `amount_cents` field: * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds \ -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"reason": "customer_request"}' ``` * Node.js ```js const res = await fetch( "https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds", { method: "POST", headers: { "Authorization": "Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ reason: "customer_request" }), } ); const refund = await res.json(); ``` * Python ```python import requests res = requests.post( "https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds", headers={ "Authorization": "Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={"reason": "customer_request"}, ) refund = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->post( '/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds', ['json' => ['reason' => 'customer_request']] ); $refund = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.post('/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds') do |req| req.body = { reason: 'customer_request' } end refund = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var res = await http.PostAsJsonAsync( "/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds", new { reason = "customer_request" }); var refund = await res.Content.ReadFromJsonAsync(); ``` **Response** ```json { "success": true, "status": "succeeded", "transaction_id": "pi_0abc123def456ghi789jkl012mn", "refund_id": "re_0abc123def456", "message": "Full refund of 5000 AUD processed", "amount_cents": 5000, "currency": "AUD" } ``` ## Partial refund Specify `amount_cents` to refund a portion of the payment. Multiple partial refunds are allowed as long as the total does not exceed the original charged amount. * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds \ -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"amount_cents": 2000, "reason": "partial_return"}' ``` * Node.js ```js const res = await fetch( "https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds", { method: "POST", headers: { "Authorization": "Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ amount_cents: 2000, reason: "partial_return" }), } ); const refund = await res.json(); ``` * Python ```python import requests res = requests.post( "https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds", headers={ "Authorization": "Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={"amount_cents": 2000, "reason": "partial_return"}, ) refund = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->post( '/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds', ['json' => ['amount_cents' => 2000, 'reason' => 'partial_return']] ); $refund = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.post('/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds') do |req| req.body = { amount_cents: 2000, reason: 'partial_return' } end refund = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var res = await http.PostAsJsonAsync( "/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds", new { amount_cents = 2000, reason = "partial_return" }); var refund = await res.Content.ReadFromJsonAsync(); ``` **Response** ```json { "success": true, "status": "succeeded", "transaction_id": "pi_0abc123def456ghi789jkl012mn", "refund_id": "re_0def456ghi789", "message": "Partial refund of 2000 AUD processed", "amount_cents": 2000, "currency": "AUD" } ``` ## List refunds Retrieve all refunds for a payment intent: * cURL ```bash curl https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds \ -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` * Node.js ```js const res = await fetch( "https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds", { headers: { "Authorization": "Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } } ); const data = await res.json(); ``` * Python ```python import requests res = requests.get( "https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds", headers={"Authorization": "Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, ) data = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->get('/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds'); $data = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.get('/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds') data = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var data = await http.GetFromJsonAsync( "/api/v1/payment_intents/pi_0abc123def456ghi789jkl012mn/refunds"); ``` ## Request fields | Field | Type | Required | Description | | -------------- | ------- | -------- | ------------------------------------------------------------------------ | | `amount_cents` | integer | No | Amount to refund in the smallest currency unit. Omit for a full refund. | | `reason` | string | No | One of `customer_request`, `duplicate`, `fraudulent`. Passed to the PSP. | ## Refund statuses | Status | Meaning | | ----------- | --------------------------------------- | | `succeeded` | Refund confirmed by PSP | | `pending` | Submitted to PSP, awaiting confirmation | | `failed` | PSP rejected the refund | ## Constraints * The payment intent must be in `succeeded` status * Total refund amount across all refunds cannot exceed the original charged amount * A full refund transitions the payment intent status to `refunded` * A partial refund keeps the payment intent in `succeeded` * A failed refund does not affect the payment intent status — retry with a new request Caution `refund_amount_exceeds_available` means you’ve already refunded the maximum available amount. `payment_intent_not_refundable` means the intent is not in a refundable state. ## Webhook events | Event | When | | ----------------------------------- | -------------------------------------------------- | | `payment_intent.refunded` | All charged funds have been refunded | | `payment_intent.partially_refunded` | A partial refund succeeded (remaining balance > 0) | | `payment_intent.refund_failed` | Refund attempt failed | See [Webhooks](/api/guides/webhooks) for how to handle these events.
# Scheduled Payments
> Set up and manage scheduled payments with ElasticPay.
## Overview ElasticPay supports two ways to schedule a payment for a future date: 1. **Payment Plans** — the system automatically generates and processes recurring payment intents on your behalf (weekly, monthly, etc.) 2. **Direct PI scheduling** — you create a single payment intent now and set `scheduled_payment_date` to a future date; the system processes it automatically on that date This guide covers direct PI scheduling. For a full reference of payment plan lifecycle management, see [Payment Plans API](/api/guides/payment-plans-api). ## Requirements * A **saved payment method** (`pm_xxx`) must be attached to the intent at create time. Transient tokens (from the widget) are not accepted because the customer will not be present at processing time. * The date must be **today or in the future**, and at most **366 days** from today. ## Step 1 — Save a payment method If you don’t have a saved `pm_xxx` yet, create a Setup Intent to collect and save the customer’s card: * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/setup_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"customer_id": "cus_0abc123def456"}' ``` * Node.js ```js const res = await fetch("https://api.elasticpay.co/api/v1/setup_intents", { method: "POST", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ customer_id: "cus_0abc123def456" }), }); const { client_secret } = await res.json(); ``` * Python ```python import requests res = requests.post( "https://api.elasticpay.co/api/v1/setup_intents", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={"customer_id": "cus_0abc123def456"}, ) client_secret = res.json()["client_secret"] ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->post('/api/v1/setup_intents', [ 'json' => ['customer_id' => 'cus_0abc123def456'], ]); $data = json_decode($res->getBody(), true); $client_secret = $data['client_secret']; ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.post('/api/v1/setup_intents') do |req| req.body = { customer_id: 'cus_0abc123def456' } end client_secret = res.body['client_secret'] ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var res = await http.PostAsJsonAsync("/api/v1/setup_intents", new { customer_id = "cus_0abc123def456" }); var si = await res.Content.ReadFromJsonAsync(); var clientSecret = si.GetProperty("client_secret").GetString(); ``` Pass the `client_secret` to the payment widget. After the customer completes the form, the saved method `pm_xxx` is available on the customer record. ## Step 2 — Create a scheduled payment intent * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "amount": 9900, "currency": "AUD", "customer_id": "cus_0abc123def456", "payment_method": "pm_0xyz789abc123def456ghi012jkl", "scheduled_payment_date": "2026-07-01" }' ``` * Node.js ```js const res = await fetch("https://api.elasticpay.co/api/v1/payment_intents", { method: "POST", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ amount: 9900, currency: "AUD", customer_id: "cus_0abc123def456", payment_method: "pm_0xyz789abc123def456ghi012jkl", scheduled_payment_date: "2026-07-01", }), }); const pi = await res.json(); ``` * Python ```python import requests res = requests.post( "https://api.elasticpay.co/api/v1/payment_intents", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={ "amount": 9900, "currency": "AUD", "customer_id": "cus_0abc123def456", "payment_method": "pm_0xyz789abc123def456ghi012jkl", "scheduled_payment_date": "2026-07-01", }, ) pi = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->post('/api/v1/payment_intents', [ 'json' => [ 'amount' => 9900, 'currency' => 'AUD', 'customer_id' => 'cus_0abc123def456', 'payment_method' => 'pm_0xyz789abc123def456ghi012jkl', 'scheduled_payment_date' => '2026-07-01', ], ]); $pi = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.post('/api/v1/payment_intents') do |req| req.body = { amount: 9900, currency: 'AUD', customer_id: 'cus_0abc123def456', payment_method: 'pm_0xyz789abc123def456ghi012jkl', scheduled_payment_date: '2026-07-01' } end pi = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var res = await http.PostAsJsonAsync("/api/v1/payment_intents", new { amount = 9900, currency = "AUD", customer_id = "cus_0abc123def456", payment_method = "pm_0xyz789abc123def456ghi012jkl", scheduled_payment_date = "2026-07-01", }); var pi = await res.Content.ReadFromJsonAsync(); ``` The response will have `status: "requires_confirmation"` and `scheduled_payment_date: "2026-07-01"`. ## Step 3 — Confirm to lock in the schedule * cURL ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123/confirm \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{}' ``` * Node.js ```js const res = await fetch( "https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123/confirm", { method: "POST", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({}), } ); const result = await res.json(); ``` * Python ```python import requests res = requests.post( "https://api.elasticpay.co/api/v1/payment_intents/pi_0abc123/confirm", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={}, ) result = res.json() ``` * PHP ```php 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ], ]); $res = $client->post('/api/v1/payment_intents/pi_0abc123/confirm', ['json' => []]); $result = json_decode($res->getBody(), true); ``` * Ruby ```ruby require 'faraday' require 'json' conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :json end conn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' conn.headers['Content-Type'] = 'application/json' res = conn.post('/api/v1/payment_intents/pi_0abc123/confirm') do |req| req.body = {} end result = res.body ``` * C# ```csharp using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); var res = await http.PostAsJsonAsync( "/api/v1/payment_intents/pi_0abc123/confirm", new {}); var result = await res.Content.ReadFromJsonAsync(); ``` Because the date is in the future, the response status will be **`ready`** — the payment is queued for automatic processing on `scheduled_payment_date`. No further action is needed. ## `ready` status A payment intent in `ready` status has been confirmed and is waiting for its scheduled date. | Status | Meaning | | ------------ | ------------------------------------- | | `ready` | Confirmed, waiting for scheduled date | | `processing` | Submitted to PSP | | `succeeded` | Payment collected | | `failed` | Payment declined or errored | ## Webhook events Subscribe to these events to monitor scheduled payment activity: | Event | When | | --------------------------- | ---------------------------------------- | | `payment_intent.succeeded` | Scheduled payment collected successfully | | `payment_intent.failed` | Payment declined or failed | | `payment_intent.processing` | Submitted to PSP, awaiting result | See [Webhooks](/api/guides/webhooks) for how to handle these events.
# Test Clocks
> Simulate the passage of time to test billing flows in minutes, not months.
A **test clock** simulates the passage of time for a chosen set of sandbox customers. Advance the clock and the billing engine evaluates everything scheduled for those customers as if the clock’s new `frozen_time` were now — plan renewals, subscription rollovers, dunning retries, payment-link expiry. A month of billing takes seconds instead of a month. Test clocks are **sandbox-only**. They never touch live customers or move real money, and requests with a live key are rejected. ## How it works 1. **Create a clock** with a starting `frozen_time` (the simulated “now”) 2. **Create customers attached to the clock** — everywhere the billing engine asks “what time is it?”, these customers get the clock’s time 3. **Attach billing objects** to those customers: payment plans, subscriptions, payment links 4. **Advance the clock** to a new `frozen_time` — the advance runs asynchronously 5. **Observe the effects** — the `test_clock.ready` webhook reports what fired, and the affected objects are in their post-advance states 6. **Repeat** as needed within the clock’s 30-day lifetime ## Quick start ### 1. Create a test clock ```bash curl -X POST https://api.elasticpay.co/api/v1/test_helpers/test_clocks \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"name": "Monthly renewal walkthrough", "frozen_time": "2026-01-01T00:00:00Z"}' ``` Response (`201 Created`): ```json { "id": "clk_3kEZpy0UsPbAlvbiGzYEYtSi", "object": "test_clock", "name": "Monthly renewal walkthrough", "status": "created", "frozen_time": "2026-01-01T00:00:00.000Z", "expires_at": "2026-02-08T14:20:05.000Z", "livemode": false } ``` `expires_at` is 30 days after the clock is **created** (wall clock), not 30 days after `frozen_time`. ### 2. Create a customer attached to the clock ```bash curl -X POST https://api.elasticpay.co/api/v1/customers \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "name": "Alex Smith", "email": "alex@example.com", "test_clock": "clk_3kEZpy0UsPbAlvbiGzYEYtSi" }' ``` The `test_clock` field can only be set at creation and is immutable — you cannot attach or detach a clock from an existing customer. ### 3. Advance the clock Attach a payment plan or subscription to the customer first (see the [worked examples](#worked-examples) below), then advance: ```bash curl -X POST https://api.elasticpay.co/api/v1/test_helpers/test_clocks/clk_3kEZpy0UsPbAlvbiGzYEYtSi/advance \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"frozen_time": "2026-02-01T00:00:00Z"}' ``` Response (`202 Accepted`) — note the clock still shows the **old** `frozen_time`; the advance completes asynchronously: ```json { "id": "clk_3kEZpy0UsPbAlvbiGzYEYtSi", "status": "advancing", "frozen_time": "2026-01-01T00:00:00.000Z" } ``` You’ll receive `test_clock.advancing` when the advance is accepted, then `test_clock.ready` when the billing effects have completed — or poll the clock until `status` is `ready`. ### 4. Retrieve the updated clock ```bash curl https://api.elasticpay.co/api/v1/test_helpers/test_clocks/clk_3kEZpy0UsPbAlvbiGzYEYtSi \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` ```json { "id": "clk_3kEZpy0UsPbAlvbiGzYEYtSi", "status": "ready", "frozen_time": "2026-02-01T00:00:00.000Z", "last_advanced_at": "2026-01-09T14:23:11.000Z" } ``` `frozen_time` is simulated time; `last_advanced_at` is the real wall-clock moment the advance completed. ## API reference ### The test clock object | Field | Type | Description | | ------------------ | ------------------ | ------------------------------------------------------ | | `id` | string (`clk_...`) | Unique identifier | | `object` | `"test_clock"` | Always `"test_clock"` | | `name` | string or null | Optional label for the scenario | | `status` | enum | `created` / `advancing` / `ready` / `failed` | | `frozen_time` | ISO 8601 | Simulated current time for the clock’s customers | | `expires_at` | ISO 8601 | 30 days after creation; the clock is auto-deleted then | | `last_advanced_at` | ISO 8601 or null | Wall-clock time of the last completed advance | | `livemode` | `false` | Always `false` — clocks are sandbox-only | ### Endpoints | Method | Path | Description | | -------- | ---------------------------------------------- | -------------------------------------------- | | `POST` | `/api/v1/test_helpers/test_clocks` | Create a clock | | `GET` | `/api/v1/test_helpers/test_clocks` | List clocks (newest first) | | `GET` | `/api/v1/test_helpers/test_clocks/:id` | Retrieve a clock | | `POST` | `/api/v1/test_helpers/test_clocks/:id/advance` | Advance a clock | | `DELETE` | `/api/v1/test_helpers/test_clocks/:id` | Delete a clock and everything attached to it | All endpoints require a **sandbox secret key** with the `test_clocks:read` or `test_clocks:write` scope. A live key returns `403` with code `sandbox_only`. **Create** — `frozen_time` (required, ISO 8601), `name` (optional). `422` if you already have 3 active clocks or `frozen_time` is missing. **List** — `limit` (default 20, max 100) and `starting_after` (a `clk_...` id) for cursor pagination. Returns `{ "data": [...], "has_more": true|false }`. **Advance** — `frozen_time` (required) must be **after** the current `frozen_time` and within the [advance bound](#the-advance-bound). The clock must be in `created` or `ready` status. **Delete** — removes the clock **and all of its customers**, along with their payment plans, payment intents, subscriptions, and mandates. This is irreversible. ## The advance bound A single advance can move time forward by at most **two billing intervals** of the shortest active plan or subscription attached to the clock’s customers: ```plaintext max advance target = frozen_time + (2 × shortest_billing_interval) ``` The interval accounts for the plan’s full frequency — a weekly plan is 7 days, a fortnightly plan 14, a monthly plan 30; subscriptions count as 30 days. With no billing objects attached yet, a default 31-day interval applies, giving a 62-day window. If an advance exceeds the bound, the `422` error states the maximum allowed target time. Need to go further? Issue multiple advances — each fires its own `test_clock.advancing` / `test_clock.ready` pair, so you observe each cycle’s effects incrementally. ## Webhook events Delivered to your **sandbox** webhook subscriptions only: | Event | Fires when | Notable payload fields | | ---------------------- | ------------------------------------- | --------------------------------- | | `test_clock.created` | Clock created | Clock object | | `test_clock.advancing` | Advance accepted | Clock object + `target_time` | | `test_clock.ready` | Advance completed | Clock object + `effects_manifest` | | `test_clock.deleted` | Clock deleted (manually or at expiry) | Clock object at deletion | There is no failure webhook — if an advance fails, the clock’s `status` becomes `failed` (see the [FAQ](#faq)). The `effects_manifest` in `test_clock.ready` summarises what the advance produced: ```json { "payment_intents_enqueued": 2, "links_expired": 1, "subscriptions_rolled": 1, "plans_recalculated": 2 } ``` ## Worked examples ### Test a monthly payment plan renewal ```plaintext Create clock at Jan 1 Create customer attached to the clock Create a monthly payment plan for that customer, start_date Jan 1 → plan activates; first payment scheduled for Jan 1 Advance to Jan 1 noon → test_clock.ready: payment_intents_enqueued: 1 — first payment succeeds Advance to Feb 1 → test_clock.ready: payment_intents_enqueued: 1 — renewal fired → check the payment intent: succeeded; next payment scheduled Mar 1 ``` ### Test dunning — a payment fails, the retry fires ```plaintext Create clock at Jan 1 Create customer + plan where the collection will decline (force the outcome — see Forcing payment outcomes on the Test Cards page) Advance to Jan 1 noon → payment dispatches and fails; the plan's failure handling queues a retry Advance to Jan 4 (the retry window) → test_clock.ready: payment_intents_enqueued: 1 → check whether the retry succeeded or failed ``` ### Test payment-link expiry ```plaintext Create clock at Jan 1, customer attached Create a payment link expiring Jan 7 — status: active Advance to Jan 8 → test_clock.ready: links_expired: 1 → link status is expired and no longer payable ``` ### Test subscription period rollover ```plaintext Create clock at Jan 1, customer with a monthly subscription Advance to Feb 1 → test_clock.ready: subscriptions_rolled: 1 → current period is now Feb 1 – Mar 1 ``` ## Limits and lifetime | Limit | Value | | ----------------------------------------- | ------------------------ | | Customers per clock | 3 | | Active clocks per sandbox account | 3 | | Clock-bound customers per sandbox account | 9 | | Clock lifetime | 30 days from creation | | Advance direction | Forward only — no rewind | | Advance bound per call | 2 billing intervals | When a clock expires or is deleted, **all of its customers and their billing objects are deleted permanently**. Design scenarios around this lifetime, or delete and recreate clocks between test sessions. Not everything moves with the clock — see [What Test Clocks Simulate](/api/concepts/test-clock-simulation) before building scenarios around settlement or BECS collection outcomes. ## FAQ **Can I use a live API key?** No. `test_helpers` endpoints reject live keys with `403 sandbox_only`. **Can I attach an existing customer to a clock?** No — `test_clock` is set at customer creation and is immutable. Create a new customer on the clock. **Can I rewind?** No. Clocks are forward-only. To re-run a scenario, delete the clock (which deletes its customers) and create a fresh one. **What happens at expiry?** After 30 days the clock, its customers, and their billing objects are deleted automatically. There is no recovery. **My advance was rejected for exceeding the bound.** You asked for a target beyond `frozen_time + 2 × shortest interval` — the error message includes the maximum allowed time. Advance in smaller steps. **The clock is stuck in `advancing` or shows `failed`.** A `failed` clock cannot be advanced again — delete it and recreate the scenario. If a clock stays in `advancing` unusually long, check its status in the dashboard (**Developer → Test Clocks**).
# Webhooks
> Receive real-time event notifications from ElasticPay.
## Overview Webhooks push event notifications to your server when asynchronous state changes occur. Rather than polling the API to check payment status, configure a webhook endpoint and let ElasticPay notify you. Webhook endpoints are configured by ElasticPay — there is no self-serve webhook screen in the dashboard. To add or change one, contact support with: * the endpoint URL (publicly reachable, HTTPS, not `localhost`) * the event types you want to receive * whether it is for your sandbox or live account Sandbox and live accounts have separate subscriptions, each with its own signing secret. Sandbox events are only delivered to sandbox endpoints, and live events only to live endpoints. Secret rotation also goes through support. Once events are flowing, the delivery status of an individual event is shown in the **Events** panel on the payment intent, payment plan, or customer detail page. ## Event envelope Every webhook event has the same structure: ```json { "version": "1", "event_id": "evt_0abc123def456ghi789jkl", "event_type": "payment_intent.succeeded", "biller_id": "biller_abc123", "livemode": false, "emitted_at": "2025-01-15T10:05:00Z", "data": { "id": "pi_0abc123def456ghi789jkl012mn", "status": "succeeded", "amount": 5000, "currency": "AUD" } } ``` | Field | Description | | ------------ | ----------------------------------------------- | | `version` | Envelope schema version | | `event_id` | Unique event identifier — use for deduplication | | `event_type` | Event name (see table below) | | `biller_id` | Account that generated the event | | `livemode` | `true` for live events, `false` for sandbox | | `emitted_at` | ISO 8601 timestamp | | `data` | The resource at the time of the event | ## Event types | Event | Description | | ----------------------------------- | ---------------------------------------------------------- | | `payment_intent.succeeded` | Payment completed successfully | | `payment_intent.failed` | Payment declined or failed | | `payment_intent.processing` | Submitted to PSP, awaiting result | | `payment_intent.canceled` | Payment intent was canceled | | `payment_intent.refunded` | Full refund succeeded | | `payment_intent.partially_refunded` | Partial refund succeeded | | `payment_intent.refund_failed` | Refund attempt failed | | `setup_intent.succeeded` | Payment method saved successfully | | `setup_intent.failed` | Setup intent failed | | `test_clock.created` | Sandbox test clock created | | `test_clock.advancing` | Test clock advance accepted (includes `target_time`) | | `test_clock.ready` | Test clock advance completed (includes `effects_manifest`) | | `test_clock.deleted` | Test clock deleted, manually or at expiry | ## Verifying signatures Every webhook request includes an `X-Webhook-Signature` header. Verify it to confirm the request came from ElasticPay. The signature is computed as `v1=HMAC-SHA256(secret, ".")` where `` is the value of the `X-Webhook-Timestamp` header. | Header | Description | | --------------------- | ----------------------------------------------- | | `X-Webhook-Signature` | `v1=` | | `X-Webhook-Timestamp` | ISO 8601 timestamp of when the event was signed | | `X-Webhook-Key-Id` | Identifies which signing key was used | Reject requests where the timestamp is more than 5 minutes from your server clock to prevent replay attacks. * cURL ```bash # Webhook requests include these headers: # # X-Webhook-Signature v1= # X-Webhook-Timestamp ISO 8601 timestamp # X-Webhook-Key-Id signing key identifier # # The signature is computed as: # HMAC-SHA256(secret, ".") # # Reject requests where the timestamp is more than 5 minutes # from your server clock to prevent replay attacks. ``` * Node.js ```js import { createHmac, timingSafeEqual } from "crypto"; function verifyWebhookSignature( rawBody: string, signature: string, timestamp: string, secret: string ): boolean { const payload = `${timestamp}.${rawBody}`; const expected = `v1=${createHmac("sha256", secret).update(payload).digest("hex")}`; return timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); } // Express handler app.post("/webhooks/elasticpay", (req, res) => { const signature = req.headers["x-webhook-signature"] as string; const timestamp = req.headers["x-webhook-timestamp"] as string; const isValid = verifyWebhookSignature( JSON.stringify(req.body), signature, timestamp, process.env.WEBHOOK_SECRET! ); if (!isValid) return res.status(401).send("Invalid signature"); res.status(200).send("OK"); // Process the event asynchronously }); ``` * Python ```python import hmac import hashlib import os from flask import Flask, request, abort app = Flask(__name__) def verify_webhook_signature(raw_body: bytes, signature: str, timestamp: str) -> bool: secret = os.environ["WEBHOOK_SECRET"].encode() payload = f"{timestamp}.{raw_body.decode()}".encode() expected = "v1=" + hmac.new(secret, payload, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) @app.post("/webhooks/elasticpay") def webhook_handler(): signature = request.headers.get("X-Webhook-Signature", "") timestamp = request.headers.get("X-Webhook-Timestamp", "") if not verify_webhook_signature(request.get_data(), signature, timestamp): abort(401) # Respond 200 immediately; process asynchronously return "", 200 ``` * PHP ```php { using var reader = new StreamReader(req.Body); var rawBody = await reader.ReadToEndAsync(); var signature = req.Headers["X-Webhook-Signature"].ToString(); var timestamp = req.Headers["X-Webhook-Timestamp"].ToString(); var secret = Environment.GetEnvironmentVariable("WEBHOOK_SECRET")!; if (!VerifyWebhookSignature(rawBody, signature, timestamp, secret)) return Results.Unauthorized(); return Results.Ok(); // Process event asynchronously }); ``` ## Best practices * **Respond 200 immediately.** Process events asynchronously — do not make slow API calls inside the handler. * **Make handlers idempotent.** Events may be delivered more than once. Use `event_id` to deduplicate. * **Verify `livemode`.** Match the flag to your environment to avoid processing test events in production. * **Expect retries.** If your endpoint returns a non-2xx status, ElasticPay retries delivery with exponential backoff.
# Payment Methods
> The payment methods ElasticPay supports, and what each one can do.
ElasticPay supports two payment methods: **cards** and **BECS Direct Debit**. Every integration — whatever the channel — resolves to one of these. ## At a glance | | [Card](/api/payment-methods/card) | [BECS Direct Debit](/api/payment-methods/becs-direct-debit) | | ----------------------- | ----------------------------------------- | ----------------------------------------------------------- | | Schemes / rails | Visa, Mastercard, Amex (card-not-present) | Australian bank accounts (BSB + account number) | | Processing | Real-time | Batch | | Settlement | T+1 | T+3 | | Save for later use | ✅ Tokenised (`pm_xxx`) | ✅ Tokenised, with a direct debit mandate | | Recurring | ✅ | ✅ | | Refunds | ✅ Full and partial | ✅ Full and partial | | Customer authentication | 3D Secure where required | Electronic mandate (DDR) acceptance | | Failure mode | Immediate decline | Dishonour, reported after processing | | Region | Australia | Australia | | Channels | Hosted, embedded, direct API | Embedded, direct API | ## How methods, channels, and use cases fit together Choose in this order: 1. **[Use case](/api/use-cases)** — what you’re trying to do (one-off payment, save a method, recurring billing, virtual terminal) 2. **[Channel](/api/channels)** — how you collect payment details (hosted page, embedded widget, direct API) 3. **Method** — what the customer pays with (card or BECS) The use case determines what information must be captured at the moment of authorisation. Getting this right is what makes saved-method and recurring charges work reliably — see [Stored Credentials & Recurring Charges](/api/concepts/stored-credentials).
# BECS Direct Debit
> Accept Australian bank account payments via BECS Direct Debit.
## Overview BECS Direct Debit draws funds directly from an Australian bank account. Unlike cards, BECS is a **batch** rail: payments are submitted for processing and settle T+3, and a payment that appears to be processing can still dishonour several business days later. ## What the customer provides BSB, account number, and account name — plus electronic acceptance of a **Direct Debit Request (DDR)**. The [BECS widget](/api/widget/embedding#becs-widget) collects all of this, including the mandate acceptance evidence, in one flow. ## The mandate Every BECS payment must be authorised by an active direct debit mandate. When a customer completes the BECS widget flow, ElasticPay: * captures electronic acceptance of the DDR (timestamp, IP, user agent) * stores the mandate and retains the evidence for the BECS-required period * returns a payment method token (`pm_xxx`) linked to that mandate You don’t manage mandate documents yourself — ElasticPay generates and retains them. ## Collecting bank details **Embedded widget (recommended):** ```ts import { ElasticPayBecsWidget } from "https://pay.elasticpay.co/v1/widget.js"; const widget = new ElasticPayBecsWidget("becs-widget", { apiUrl: "https://api.elasticpay.co", publishableKey: "pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", clientSecret: "pi_0abc123_secret_xyz987", onTokenize: (result) => { console.log("BECS payment method:", result.id); }, }); ``` **Direct API** (server-side, where you already hold the details under an existing authority): ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_methods \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "type": "au_becs_debit", "payment_method_data": { "bsb_number": "062000", "account_name": "Jane Citizen", "account_number": "12345678" } }' ``` Caution The hosted checkout page collects card payments only. For BECS, use the embedded BECS widget or the direct API. ## Processing and settlement | Property | Value | | ---------- | ------------------------------------------------------------------- | | Processing | Batch — submitted on business days | | Settlement | T+3 | | Dishonours | Reported after processing; a payment can fail days after submission | | Currency | AUD | Treat `processing` as genuinely pending for BECS: don’t release goods or services on submission alone. The `payment_intent.succeeded` webhook is the authoritative signal, and a dishonour arrives as `payment_intent.failed`. ## Recurring charges BECS is built for recurring billing: once the mandate is in place, charge the saved `pm_xxx` on your schedule or via a [payment plan](/api/use-cases/recurring-with-a-payment-plan) — no customer interaction required. ## Refunds and failures * **Refunds** — supported via the [Refunds](/api/guides/refunds) API. * **Dishonours** — failed debits (insufficient funds, closed account, cancelled authority) surface as `payment_intent.failed` with a reason code. Repeated dishonours automatically cancel the mandate.
# Card
> Accept Visa, Mastercard, and Amex card payments with ElasticPay.
## Overview ElasticPay processes card-not-present payments for **Visa**, **Mastercard**, and **Amex**. Card payments process in real time and settle T+1. ## What the customer provides Card number, expiry, CVV, and cardholder name — collected by the [hosted payment page](/api/channels/hosted-payment-page) or the [embedded widget](/api/widget/embedding). ## What you collect (and what you never touch) Your integration only ever handles a **payment method token** (`pm_xxx`). The card number and CVV are captured inside an ElasticPay-controlled frame and tokenised before anything reaches your systems — they never pass through your page’s DOM or your servers. This is what keeps your PCI obligation at SAQ A. See [Security & PCI Compliance](/api/concepts/security-pci). Never collect raw card numbers in your own forms or send them to your server. ## Processing and settlement | Property | Value | | -------------- | --------------------------------------------------------------- | | Authorisation | Real-time — the confirm response tells you the outcome | | 3D Secure | Applied automatically where required (`requires_action` status) | | Settlement | T+1 | | Minimum amount | 200 cents | | Currency | AUD | ## Saving cards and recurring charges Cards can be saved for later use with a [Setup Intent](/api/use-cases/save-a-payment-method) or by flagging a first purchase to store the method. Saved cards support merchant-initiated recurring charges — ElasticPay handles the card-scheme stored-credential requirements automatically. See [Stored Credentials & Recurring Charges](/api/concepts/stored-credentials). ## Refunds and failures * **Refunds** — full and partial, via the [Refunds](/api/guides/refunds) API. * **Declines** — returned immediately as `failed` with an error code. See [Error Codes](/api/resources/error-codes). ## Surcharging Per-card-type surcharging (domestic/international) is supported and configured in the merchant portal, compliant with RBA surcharging standards. ## Testing Use the [test card numbers](/api/resources/test-cards) in sandbox mode to exercise successful payments, declines, and 3D Secure challenges.
# Building with LLMs
> Machine-readable docs for AI coding agents — llms.txt, markdown pages, and the OpenAPI schema.
If you’re integrating ElasticPay with an AI coding assistant or agent, the documentation is available in machine-friendly formats. Point your tool at these instead of scraping HTML. ## Resources | Resource | URL | Use for | | -------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Agent index | [`/llms.txt`](https://docs.elasticpay.co/llms.txt) | Entry point: what exists, how to fetch it, and integration rules agents must follow | | Full docs, one file | [`/llms-full.txt`](https://docs.elasticpay.co/llms-full.txt) | Loading the complete documentation into a large context window | | Compact docs | [`/llms-small.txt`](https://docs.elasticpay.co/llms-small.txt) | Smaller context windows — integration content only | | Any page as Markdown | append `.md` to its URL | Fetching a single page without HTML, e.g. [`/api/guides/test-clocks.md`](https://docs.elasticpay.co/api/guides/test-clocks.md) | | OpenAPI schema | [`api.elasticpay.co/openapi.json`](https://api.elasticpay.co/openapi.json) | Exact request/response shapes — prefer this over prose for code generation | ## Suggested workflow 1. Give your agent `https://docs.elasticpay.co/llms.txt` — it includes instructions covering the constraints that matter most (supported payment methods, tokenisation rules, sandbox keys, webhook semantics). 2. For endpoint-level work, have it fetch the OpenAPI schema rather than relying on remembered API shapes. 3. If you have no sandbox key yet, hand the human and ask for the `sk_sandbox_` key back — an agent cannot create an ElasticPay account itself. See [Get a sandbox API key](/api/getting-started/get-a-sandbox-key/). 4. Develop against **sandbox keys** and verify time-dependent flows with [test clocks](/api/guides/test-clocks) — an agent can watch a payment plan renew in seconds instead of waiting a month. Caution Never let an agent handle raw card numbers or CVV — card capture goes through the [widget or hosted payment page](/api/channels/) only, and server-side code handles `pm_...` tokens exclusively. This applies to generated code too.
# Changelog
> A record of changes to the ElasticPay API.
Most recent changes are listed first. ## 2026 **Test clocks.** Simulate the passage of time in sandbox: fast-forward payment plan renewals, dunning retries, subscription rollovers, and payment-link expiry. See the [Test Clocks guide](/api/guides/test-clocks) and [What Test Clocks Simulate](/api/concepts/test-clock-simulation). ## 2025 **Initial public documentation release.** Covers: * Payment intent lifecycle and API * Widget embedding and customization * Webhooks and event reference * Recurring payments and setup intents * Portal: accounts, customers, payment plans * Error codes and test resources
# Error Codes
> Complete reference for ElasticPay API error codes.
## Error response shape All API errors return a consistent JSON structure: ```json { "error": { "type": "invalid_request_error", "code": "validation_failed", "message": "amount must be at least 200 cents", "details": [ { "field": "amount", "message": "must be greater than or equal to 200" } ] } } ``` | Field | Description | | --------- | ---------------------------------------------------- | | `type` | Error category (see below) | | `code` | Machine-readable code | | `message` | Human-readable description | | `details` | Array of field-level errors (validation errors only) | ## HTTP status codes | Status | Meaning | | ------ | ------------------------------------------------------------ | | `400` | Validation error or malformed request | | `401` | Missing or invalid authentication credentials | | `403` | Authenticated but not permitted for this operation | | `404` | Resource not found | | `409` | Conflict (e.g. idempotency key reused with different params) | | `500` | Internal server error | ## Error types | Type | Description | | ----------------------- | ------------------------------------------------------ | | `invalid_request_error` | Request is malformed or fails validation | | `authentication_error` | API key is missing, invalid, revoked, or expired | | `permission_error` | Key doesn’t have permission for this operation | | `api_error` | Internal server error — retry with exponential backoff | ## Error code reference | Code | HTTP | Description | | --------------------------------- | ---- | ------------------------------------------------------ | | `invalid_api_key_format` | 401 | Key does not match expected format | | `permission_denied` | 403 | Key is valid but not permitted for this resource | | `payment_intent_not_found` | 404 | No payment intent with that ID exists | | `invalid_payment_method_type` | 400 | Unsupported `type` value in payment method create | | `refund_amount_exceeds_available` | 400 | Sum of refunds would exceed original payment amount | | `payment_intent_not_refundable` | 400 | Payment intent is not in `succeeded` status | | `refund_failed` | 400 | PSP rejected the refund | | `validation_failed` | 400 | One or more fields failed validation — check `details` | ## Test helper errors Sandbox-only endpoints (`/api/v1/test_helpers/...`) add these codes: | Status | Code | Meaning | | ------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------- | | `403` | `sandbox_only` | The request used a live key — test helpers work only in sandbox mode | | `404` | `test_clock_not_found` | No test clock with that ID on this account | | `422` | — | Clock limits exceeded, invalid `frozen_time`, or an advance beyond the allowed bound (the message states the maximum) |
# Test Cards
> Use these test card numbers to simulate payments in test mode.
## Visa Use this card to simulate a successful payment: | Field | Value | | ------ | ------------------------------ | | Number | `4111 1111 1111 1111` | | Expiry | Any future date (e.g. `12/30`) | | CVC | Any 3 digits (e.g. `123`) | ## Card brand detection The widget detects the card brand from the card number prefix: | Brand | Number prefix | | ---------------- | ------------- | | Visa | `4` | | Mastercard | `51`–`55` | | American Express | `34`, `37` | | Discover | `6011`, `65` | | Diners Club | `36`, `38` | | JCB | `35` | ## BECS direct debit test accounts For Australian bank account (BECS) payments: | Field | Value | | -------------- | ---------- | | BSB | `062-000` | | Account number | Any number | | Account name | Any name | ## Forcing payment outcomes To force a specific outcome regardless of card details, set `_sandbox_outcome` in the payment intent’s metadata: ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_intents \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "amount": 1999, "currency": "AUD", "metadata": {"_sandbox_outcome": "decline"} }' ``` | Value | Result | | ----------------- | -------------------------------------------------- | | `succeed` | Payment succeeds (the default without an override) | | `decline` | Payment fails with `insufficient_funds` | | `requires_action` | Simulates a 3D Secure challenge | | `timeout` | Payment fails with `gateway_timeout` | This works for any payment method and takes priority over card-number-based outcomes. Refunds have a parallel override — set `"_sandbox_refund_outcome": "fail"` in the refund request’s metadata to make the refund fail (refunds succeed by default). ## Fixture tokens — server-side testing without the widget For CI pipelines, server-side tests, and agents, you can skip the widget entirely: pass a fixture token as `token_metadata.token` when creating a payment method (sandbox keys only). ```bash curl -X POST https://api.elasticpay.co/api/v1/payment_methods \ -H "Authorization: Bearer pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "type": "card", "payment_method_data": { "token_metadata": { "type": "fixture", "token": "tok_sandbox_visa_succeed", "data": "" } } }' ``` | Token | Brand | last4 | Outcome when charged | | --------------------------------------------- | ---------- | ----- | --------------------------------- | | `tok_sandbox_visa_succeed` | Visa | 4242 | Succeeds | | `tok_sandbox_visa_decline_insufficient_funds` | Visa | 0002 | Declines — insufficient funds | | `tok_sandbox_visa_decline_card_lost` | Visa | 9987 | Declines — card lost | | `tok_sandbox_visa_3ds_required` | Visa | 3155 | Requires 3D Secure authentication | | `tok_sandbox_visa_timeout` | Visa | 0119 | Fails — gateway timeout | | `tok_sandbox_mastercard_succeed` | Mastercard | 4444 | Succeeds | | `tok_sandbox_amex_succeed` | Amex | 8431 | Succeeds | The outcome travels with the saved payment method: every later charge against it resolves to that outcome, regardless of amount. That makes the declining tokens the right tool for testing recurring-billing failure handling — save a declining card, attach a payment plan, and [advance a test clock](/api/guides/test-clocks) to watch the dunning flow run. ## Amount-based outcomes When no metadata override or fixture outcome applies, the **last two digits of the amount in cents** select the outcome: | Amount ends in | Outcome | | -------------- | --------------------------------- | | `01` | Declines — insufficient funds | | `02` | Requires 3D Secure authentication | | `99` | Fails — gateway timeout | | anything else | Succeeds | For refunds, an amount ending in `03` fails; anything else succeeds. Prefer `_sandbox_outcome` metadata or fixture tokens — they leave amounts free to carry realistic values, so a `$19.99` subscription in a test stays `$19.99`. Watch for accidental triggers: a test amount of `1001` cents declines by design. ## Outcome precedence 1. `_sandbox_outcome` in the payment intent’s metadata (per-charge override) 2. The saved payment method’s fixture outcome 3. Amount-based fallback 4. Default: succeed ## Notes Caution Test card numbers only work with `_sandbox_` keys. They will be rejected by live PSPs. Never use real card numbers in sandbox mode. All test transactions use mocked PSP responses. Webhook events still fire normally in sandbox mode, giving you a realistic integration test environment.
# Customization
> Customize the look and feel of the ElasticPay payment widget.
## Appearance config Pass an `appearance` object when initializing the widget to customize its look: ```ts const widget = new ElasticPayCardWidget("card-widget", { apiUrl: "https://staging-api.elasticpay.co", publishableKey: "pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", clientSecret: "pi_0abc123_secret_xyz987", appearance: { variables: { colorPrimary: "#2563eb", colorBackground: "#ffffff", colorText: "#111827", colorDanger: "#dc2626", fontFamily: "Archivo, sans-serif", fontSize: "16px", labelFontSize: "14px", inputFontSize: "16px", borderRadius: "6px", }, }, }); ``` ### CSS variables reference | Variable | Default | Description | | ----------------- | --------- | ---------------------------------------------------------------------------------------------- | | `colorPrimary` | `#ec3013` | Button and focus ring color | | `colorBackground` | `#ffffff` | Input background color | | `colorText` | `#201e1d` | Input and label text color | | `colorDanger` | `#ae1800` | Error and invalid state color | | `colorSuccess` | `#16a34a` | Success state color (direct debit widget) | | `fontFamily` | `inherit` | Font family for all widget text. Defaults to `inherit`, so the widget adopts your page’s font. | | `fontSize` | `16px` | Base font size | | `labelFontSize` | `14px` | Label font size | | `inputFontSize` | `16px` | Input field font size | | `borderRadius` | `8px` | Border radius for inputs and buttons | The widget ships no web font of its own and requests no font file, so embedding it adds zero font requests to your page. By default it inherits your page’s typography; set `fontFamily` only if you want it to differ from the surrounding page. ## CSS class overrides For deeper customization, target these stable class names: | Class | Element | | ------------------------------------------ | ---------------------- | | `.elasticpay-card-widget` | Root container | | `.elasticpay-card-widget__input` | Card input fields | | `.elasticpay-card-widget__input--valid` | Input in valid state | | `.elasticpay-card-widget__input--invalid` | Input in invalid state | | `.elasticpay-card-widget__submit` | Submit button | | `.elasticpay-card-widget__status--success` | Success message | | `.elasticpay-card-widget__status--error` | Error message | Example: ```css .elasticpay-card-widget__input { border: 1px solid #d1d5db; padding: 10px 12px; } .elasticpay-card-widget__input--invalid { border-color: #dc2626; } .elasticpay-card-widget__submit { background-color: #2563eb; font-weight: 600; } ``` ## Hide the submit button Set `showSubmitButton: false` to hide the built-in submit button and trigger tokenization manually: ```ts const widget = new ElasticPayCardWidget("card-widget", { apiUrl: "https://staging-api.elasticpay.co", publishableKey: "pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", clientSecret: "pi_0abc123_secret_xyz987", showSubmitButton: false, }); document.getElementById("pay-button")?.addEventListener("click", async () => { await widget.tokenize(); }); ``` Use this when the submit button is outside the widget container, or when you need to validate other form fields before triggering payment.
# Embedding the Widget
> Embed the ElasticPay payment widget into your website.
## Overview The ElasticPay widget is a JavaScript class you mount onto a container element in your page. It renders a card input form and handles tokenization — card data never passes through your server. ## Load the script Add the widget script as an ES module: ```html ``` ## HTML setup Add a container element where the widget will render: ```html ``` ## Initialize the widget ```ts import { ElasticPayCardWidget } from "https://pay.elasticpay.co/v1/widget.js"; const widget = new ElasticPayCardWidget("card-widget", { apiUrl: "https://staging-api.elasticpay.co", publishableKey: "pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", clientSecret: "pi_0abc123_secret_xyz987", onTokenize: (result) => { // Send result.id to your server to confirm the payment console.log("Payment method:", result.id); }, onError: (error) => { console.error("Widget error:", error.message); }, }); ``` Required config options: | Option | Description | | ---------------- | --------------------------------------------------------------------------------- | | `apiUrl` | `https://staging-api.elasticpay.co` (or `https://api.payfac.local` for local dev) | | `publishableKey` | Your `pk_sandbox_...` or `pk_live_...` key | | `clientSecret` | The `client_secret` from the payment intent | ## Data attributes approach Configure the widget via HTML data attributes instead of JavaScript: ```html ``` Then initialize with no config object: ```ts const widget = new ElasticPayCardWidget("card-widget"); ``` ## Hosted checkout (simplest option) The hosted checkout redirects the customer to an ElasticPay-hosted payment page. No widget embedding required: ```html ``` After the customer completes payment, they are redirected to `return_url` with the payment intent ID as a query parameter. ## BECS widget For Australian bank account payments (BECS Direct Debit), use `ElasticPayBecsWidget`. It has the same API shape as the card widget: ```ts import { ElasticPayBecsWidget } from "https://pay.elasticpay.co/v1/widget.js"; const widget = new ElasticPayBecsWidget("becs-widget", { apiUrl: "https://staging-api.elasticpay.co", publishableKey: "pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", clientSecret: "pi_0abc123_secret_xyz987", onTokenize: (result) => { console.log("BECS payment method:", result.id); }, }); ```
# Events & Callbacks
> Handle widget events and callbacks in your integration.
## Registering callbacks Pass callbacks in the constructor config: ```ts const widget = new ElasticPayCardWidget("card-widget", { apiUrl: "https://staging-api.elasticpay.co", publishableKey: "pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", clientSecret: "pi_0abc123_secret_xyz987", onReady: () => console.log("Widget ready"), onTokenize: (result) => console.log("Token:", result.id), onError: (error) => console.error("Error:", error.message), }); ``` Or use the `.on()` method after initialization: ```ts widget.on("tokenize", (result) => { console.log("Token:", result.id); }); widget.on("error", (error) => { showError(error.message); }); ``` ## Event reference | Event | Callback signature | When it fires | | ----------- | -------------------------------------- | ---------------------------------------------- | | `ready` | `() => void` | Widget rendered and interactive | | `tokenize` | `(result: TokenizationResult) => void` | Card tokenized successfully | | `error` | `(error: WidgetError) => void` | Any error occurs | | `focus` | `(field: string) => void` | A field receives focus | | `blur` | `(field: string) => void` | A field loses focus | | `change` | `(data: FieldChangeData) => void` | Field value or validity changes | | `loading` | `(isLoading: boolean) => void` | Tokenization starts or ends | | `bin-ready` | `(data: BinReadyData) => void` | First 8 digits entered — card brand identified | ## TokenizationResult ```ts type TokenizationResult = { id: string; // "pm_0xyz789abc123..." type: string; // "card" last4: string; // "1111" bin8: string; // "41111111" expMonth: number; // 12 expYear: number; // 2026 brand: string; // "visa" used: boolean; // true after the token has been consumed once expiresAt: string; // ISO 8601 token expiry }; ``` ## WidgetError ```ts type WidgetError = { type: "validation" | "network" | "tokenization" | "unknown"; code: string; // Machine-readable error code message: string; // Human-readable message field?: string; // Which field caused the error, if applicable }; ``` ## FieldChangeData ```ts type FieldChangeData = { value: string; // Current field value (masked for card number) isValid: boolean; error?: string; // Validation error message if invalid }; ``` ## BinReadyData The `bin-ready` event fires after the first 8 digits are entered, giving you the card brand before tokenization: ```ts type BinReadyData = { bin: string; // "41111111" brand: string; // "visa" | "mastercard" | "amex" | "discover" | "diners" | "jcb" }; ``` Use this to show fee previews or card brand logos before the customer submits. ## Full event wiring example ```ts const widget = new ElasticPayCardWidget("card-widget", { apiUrl: "https://staging-api.elasticpay.co", publishableKey: "pk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", clientSecret: clientSecret, showSubmitButton: false, }); widget.on("ready", () => { document.getElementById("pay-button")!.removeAttribute("disabled"); }); widget.on("bin-ready", ({ brand }) => { document.getElementById("card-brand-icon")!.setAttribute("src", `/icons/${brand}.svg`); }); widget.on("loading", (isLoading) => { document.getElementById("pay-button")!.textContent = isLoading ? "Processing..." : "Pay"; }); widget.on("tokenize", async (result) => { const response = await fetch("/api/confirm-payment", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ paymentMethodId: result.id }), }); const data = await response.json(); if (data.status === "succeeded") { window.location.href = "/payment/success"; } }); widget.on("error", (error) => { document.getElementById("error-message")!.textContent = error.message; }); document.getElementById("pay-button")?.addEventListener("click", () => { widget.tokenize(); }); ```
# ElasticPay Docs
> Everything you need to integrate payments and manage your merchant account.