This is the full 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. If you are an AI agent You cannot create an ElasticPay account yourself — a human has to. Send yours to **** and ask them to paste back the key beginning `sk_sandbox_`. Store it in an environment variable, never in source code. Do not ask for, accept, or use a key beginning `sk_live_`: live keys move real money and belong to the account holder alone. ## 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. Tip Minimum amount is 200 cents. Supported currencies: AUD, NZD, USD, EUR, GBP, JPY. ## 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. Tip Prefer Pattern B or C where you can. The less often card details are spoken aloud and keyed by staff, the fewer disputes and the less operational risk. # 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. Note The hosted payment page collects **card** payments. To take BECS Direct Debit, use the [BECS widget](/api/widget/embedding#becs-widget) or the [direct API](/api/guides/direct-api-integration) — see [BECS Direct Debit](/api/payment-methods/becs-direct-debit). # 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 Tip Generate the idempotency key before sending the request. If the request times out, retry with the same key — you’ll get back the original result rather than a duplicate charge. # 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). Tip Don’t wait a month to see a renewal — [test clocks](/api/guides/test-clocks) fast-forward billing cycles in sandbox. 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 Tip Don’t wait a month to see a renewal — [test clocks](/api/guides/test-clocks) fast-forward billing cycles in sandbox. ## 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(); ``` Tip Listen for `payment_intent.succeeded` and `payment_intent.failed` webhooks to know the outcome. For merchant-initiated payments, rely on the webhook rather than the synchronous response. ## 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). Tip To watch a future-dated payment actually fire without waiting, use a [test clock](/api/guides/test-clocks) in sandbox. ## 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. Tip If `scheduled_payment_date` is **today**, the payment is processed immediately during the confirm step, not deferred. The response will be `processing` or `succeeded`. ## `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. Note Clock-bound customers are **excluded from the default customer list**. To see them, pass the clock explicitly: `GET /api/v1/customers?test_clock=clk_3kEZpy0UsPbAlvbiGzYEYtSi`. ### 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). Note Cards and BECS Direct Debit are the supported methods today. This page is the authoritative list — if a method isn’t here, it isn’t supported yet. # 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`. Note [Test clocks](/api/guides/test-clocks) schedule and dispatch BECS payments at simulated time, but collection outcomes stay on real time — see [What Test Clocks Simulate](/api/concepts/test-clock-simulation). ## 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` | Tip For `api_error` responses (500), retry using exponential backoff with jitter. These are transient and typically resolve within seconds. ## 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); }, }); ``` Tip For local development, use `https://api.payfac.local` as `apiUrl` and load the widget from `https://pay.payfac.local`. # 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. # Account Settings > Configure your ElasticPay merchant account settings. ## Basic settings Find these in **Settings → Account**: | Setting | Description | | -------- | ------------------------------------------------------------------------ | | Name | Biller display name | | Email | Account contact email | | Phone | Contact phone number | | Address | Registered business address | | Timezone | Used for scheduling and reporting (IANA format, e.g. `Australia/Sydney`) | | Currency | ISO 4217 — set at account creation, not changeable after activation | | Country | ISO 3166-1 alpha-2 | ## Payment settings Configure how fees are calculated and which payment methods are accepted: | Setting | Description | | ------------------- | --------------------------------------------- | | `txn_fee_cents` | Flat fee per transaction in cents | | `fee_rate_bps` | Percentage fee in basis points (100 bps = 1%) | | `surcharge_enabled` | Whether surcharges are passed to customers | | `fee_cap_cents` | Maximum fee per transaction in cents | | `fee_minimum_cents` | Minimum fee per transaction in cents | To toggle accepted payment methods (card, BECS direct debit), use the **Payment Methods** section in account settings. ## Customer attribute config Define custom fields that appear on every customer record for this account. See [Custom Attributes](/portal/accounts/custom-attributes) for configuration details. ## Lifecycle actions | Action | From state | Effect | | ------------- | ---------------- | -------------------------------------------- | | Activate Live | `pre_activation` | Starts compliance review | | Suspend | `active` | Pauses payment processing; requires a reason | | Reinstate | `suspended` | Returns account to `active` | | Deactivate | Any | Permanently closes the account | Caution Deactivation is permanent. All active payment plans stop scheduling. Contact support before deactivating if you have outstanding balances or active customers. # API Keys > Manage your ElasticPay API keys. ## Key types | Type | Prefix | Use | | --------------- | -------------------------------- | -------------------------------------------------------------- | | Secret key | `sk_sandbox_...` / `sk_live_...` | Server-side — create payments, issue refunds, manage customers | | Publishable key | `pk_sandbox_...` / `pk_live_...` | Client-side — initialize the widget, tokenize cards | Never expose a secret key in browser code, mobile apps, or public source repositories. ## Key format All keys follow the pattern: ```plaintext {type}_{mode}_{32 alphanumeric characters} ``` Examples: * `sk_sandbox_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV` * `pk_live_wX3yZ4aB5cD6eF7gH8iJ9kL0mN1oP2qR` ## Viewing keys Navigate to **Developer → API keys**, or go straight to [dashboard.elasticpay.co/developers](https://dashboard.elasticpay.co/developers). What you can see depends on the key: | Key | Visibility | | --------------------------------------- | ------------------------------------------------------------------------------------- | | Sandbox secret (`sk_sandbox_`) | Click **Reveal** to see and copy the full key at any time. It cannot move real money. | | Publishable (`pk_sandbox_`, `pk_live_`) | Shown in full — publishable keys are designed to be public. | | Live secret (`sk_live_`) | Never displayed. Only the last few characters are shown. | ## Getting more keys Your account’s secret and publishable keys are created automatically with the account, one of each per mode. Additional keys, rotation, and revocation are handled by ElasticPay support for now — contact us if you need a key rotated or invalidated. ## Primary keys Each account has one primary secret key and one primary publishable key. The primary key is the default used by the dashboard’s built-in tools. ## Revoked and expired keys A revoked or expired key returns `401 authentication_error` on all requests, and revocation cannot be undone. Revoked keys stay listed on the API keys screen so you can see what was invalidated and when. Tip Rotate secret keys when team members leave or if you suspect exposure. Ask support for a replacement key and deploy it to your server before the old one is revoked, to avoid downtime. # Custom Attributes > Add and manage custom attributes on your ElasticPay records. ## What they are Custom attributes are additional fields you define at the account level that appear on every customer record. Use them to store application-specific data alongside standard customer fields — for example, a membership number, client tier, or internal account code. ## Attribute types | Type | Description | | --------- | --------------------------- | | `string` | Short text (single line) | | `text` | Long text (multi-line) | | `date` | Date in `YYYY-MM-DD` format | | `number` | Integer or decimal number | | `boolean` | True/false checkbox | ## Configuring attributes Navigate to **Settings → Customer Attributes**. Attributes can be added, reordered, and removed from this screen. You can also import and export attribute definitions as YAML — useful for replicating configuration between sandbox and live accounts. ## Attribute definition fields | Field | Description | | ---------- | ---------------------------------------------------------- | | `name` | API key — used in code (e.g. `membership_id`) | | `label` | Display name shown in the dashboard (e.g. “Membership ID”) | | `type` | One of the types listed above | | `required` | Whether the attribute must be set when creating a customer | Example YAML: ```yaml - name: membership_id label: Membership ID type: string required: true - name: tier label: Customer Tier type: string required: false - name: joined_on label: Joined On type: date required: false ``` ## Using attributes Custom attributes appear on the customer create and edit forms in the dashboard. They are stored in the `additional_attributes` JSON field on the customer record and are searchable from the customer list. ## Validation notes Changing an attribute definition (type, required status, or removing it) does not retroactively validate or modify existing customer records. Existing data is preserved as-is. Required validation only applies to new customer creates and edits made after the configuration change. # Creating Customers > Add new customers to your ElasticPay account. ## Required fields | Field | Notes | | ----- | ------------------- | | Name | Full name | | Email | Valid email address | ## Optional fields | Field | Notes | | ---------------- | ------------------------------------------------------------- | | Reference | Your internal identifier — max 60 chars, unique per account | | Business name | Trading name if different from contact name | | Billing email | Separate email for invoices and receipts | | Timezone | IANA timezone — defaults to account timezone if blank | | Currency | ISO 4217 — defaults to account currency if blank | | Phone (mobile) | With country code | | Phone (home) | With country code | | Phone (work) | With country code | | Billing address | Country, line1, line2, city/suburb, state/region, postal code | | Shipping address | Same structure as billing address | ## Notification preferences | Preference | Description | | --------------------- | -------------------------------------------- | | `send_payment_emails` | Send email receipts for successful payments | | `send_sms_reminders` | Send SMS reminders before scheduled payments | | `send_sms_failures` | Send SMS notifications on payment failure | SMS notifications require a valid mobile phone number. ## Fee preferences | Preference | Description | | -------------------------------- | ------------------------------------- | | `customer_pays_transaction_fees` | Pass transaction fees to the customer | | `customer_pays_setup_fee` | Charge a setup fee on plan creation | These override the account-level fee settings for this customer. ## Consent The **Authorised** checkbox records that the customer has given consent for direct debit or recurring payments. Checking this field sets the `consent_authorised_at` timestamp. This timestamp is set once and is never overwritten on subsequent edits. ## Collecting a payment method When creating a customer you choose how to collect their payment method: * **Customer is with me** — collect the card or bank account immediately via the hosted payment form. * **Email the customer** — send the customer a secure link to enter their own details. If the method isn’t collected during creation (you chose **Email the customer**, or deferred), the customer is placed on `hold` with the reason *waiting for payment method*. You can collect it later from the customer detail page with **Collect Payment Details**. Once a method is successfully collected, the customer returns to `active` automatically. ## Adding a payment plan on creation Enable the **Set up payment plan** toggle during customer creation to add a plan and collect a payment method in the same flow. The plan is created in `draft` state; if the payment method is deferred, the plan activates automatically once the method is collected (see [Creating Plans](/portal/payment-plans/creating-plans)). ## Custom attributes If your account has custom attributes configured (see [Custom Attributes](/portal/accounts/custom-attributes)), they appear at the bottom of the form. Required attributes must be filled in before saving. # Deleting Customers > Remove customers from your ElasticPay account. ## Soft delete Deleting a customer is a soft delete — the record is marked as deleted and removed from the customer list, but the underlying data is retained. This preserves payment history and accounting records. ## Prerequisites Close or cancel all active payment plans before deleting a customer. A customer with active plans cannot be deleted — the deletion will be blocked with a validation error. Check the customer’s **Payment Plans** tab and close any open plans before proceeding. ## Effect After deletion: * The customer no longer appears in the customer list or search results * API calls using the customer’s `cus_xxx` ID return `404` * The customer cannot be recovered via the dashboard ## Data retention Payment history, payment intents, and transaction records associated with the customer are retained for accounting and compliance purposes. This data remains accessible in account reporting even after the customer is deleted. Tip To deactivate a customer without permanently deleting them, set their status to `cancelled` instead. Cancelled customers are hidden from active workflows but remain accessible and recoverable. # Editing Customers > Update customer details in the ElasticPay portal. ## What can be edited All core customer fields can be edited: name, email, reference, contact details, addresses, notification preferences, and fee preferences. The `external_reference_id` (`cus_xxx`) cannot be changed — it is assigned at creation and is permanent. Custom attributes can be edited at any time. A required attribute cannot be left blank. ## Changing status Use the status action buttons on the customer detail page: | Action | Result | Notes | | ----------- | ----------- | ----------------------------------------- | | Activate | `active` | Re-activates a cancelled or held customer | | Put on Hold | `hold` | Pauses payments — use for manual recovery | | Cancel | `cancelled` | Deactivates the customer | Caution Cancelling a customer with active payment plans does not automatically close those plans. Close or cancel all active plans first to avoid scheduling errors. ## Payment method recovery If a customer’s status is `hold`: 1. Click **Open Recovery Setup Intent** on the customer or plan detail page 2. A recovery email is sent to the customer with a link to update their card 3. The customer clicks the link and enters a new card via the hosted widget 4. On success, the new card is saved, hold status is cleared, and suspended plans resume automatically Click **Resend Recovery Email** if the customer did not receive the first email or the link has expired. ## Audit trail All changes to a customer record are logged in the activity log at the bottom of the detail page. Each entry shows the changed fields, old and new values, and timestamp. The `consent_authorised_at` timestamp is set once and is never overwritten. # Viewing Customers > Browse and search your customer list in the ElasticPay portal. ## Customer list Navigate to **Customers** in the sidebar to see all customers for your account. The list is paginated and sorted by creation date by default. Search fields: * Name * Email * Reference (your internal identifier) * External reference ID (`cus_xxx`) * Custom attribute values ## Customer detail Click any customer to open their detail view, which shows: * **Core fields** — name, email, reference, status, currency, timezone * **Contact details** — phone numbers, billing address, shipping address * **Payment instruments** — saved cards and bank accounts * **Payment plans** — active and historical plans * **Payment history** — all payment intents associated with this customer * **Activity log** — a record of all changes to the customer record ## Customer statuses | Status | Description | | ----------- | ------------------------------------------------------------------------------------------- | | `active` | Customer is in good standing | | `hold` | Awaiting a payment method, or payment-method recovery in progress — new payments are paused | | `cancelled` | Customer has been deactivated | A customer goes on `hold` in two situations: when they were created with a payment method still to be collected (e.g. you chose **Email the customer**, or haven’t yet completed **Collect Payment Details**), or when an existing plan’s payment method fails and needs replacing. In the recovery case, a recovery email is sent automatically. In both cases the customer returns to `active` automatically once a payment method is successfully collected — no manual change is needed. ## External reference ID The external reference ID (`cus_xxx`) is the stable identifier for a customer across the API: ```bash curl https://staging-api.elasticpay.co/api/v1/customers/cus_0abc123def456 \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` # Account Structure > Understand how accounts, merchants, and users are organised in ElasticPay. ## Entity hierarchy ```plaintext Organisation └── Biller (Account) ├── Customers │ ├── Payment Instruments │ └── Payment Plans └── Payment Intents ``` An **Organisation** is the top-level entity — it groups one or more billers and manages user access. A **Biller** (also called an Account) is the entity that accepts payments. Each biller has its own API keys, customers, and payment configuration. Most API integrations operate at the biller level. ## Biller fields | Field | Description | | ---------- | ------------------------------------------------- | | `name` | Display name for the biller | | `email` | Contact email | | `currency` | ISO 4217 currency code (e.g. `AUD`, `NZD`, `USD`) | | `country` | ISO 3166-1 alpha-2 country code (e.g. `AU`, `NZ`) | | `timezone` | IANA timezone string (e.g. `Australia/Sydney`) | | `mode` | `sandbox` or `live` | ## Sandbox and live modes Each biller has a mode: `sandbox` or `live`. A sandbox biller and a live biller are linked as a pair under the same organisation. * API keys for a sandbox biller only work in sandbox mode * Data (customers, payment intents) is not shared between modes * Switch between them using the mode toggle in the dashboard ## Account states | State | Description | | ---------------- | ---------------------------------------------------- | | `pre_activation` | Account created, not yet activated for live payments | | `active` | Account is operational | | `suspended` | Temporarily paused — payments are rejected | | `deactivated` | Permanently closed — no new payments accepted | ## Compliance states Live accounts require a compliance review before processing real payments: | State | Description | | -------------- | --------------------------------------- | | `not_required` | Sandbox mode — no review needed | | `pending` | Review not yet submitted | | `in_review` | Review submitted, awaiting decision | | `blocked` | Review cannot proceed — contact support | | `approved` | Account cleared for live payments | | `rejected` | Review failed — contact support | These states are internal to the review process; there is no compliance settings screen in the dashboard. While a live account is awaiting activation, its dashboard shows a **Continue onboarding** banner, and the onboarding application it links to shows the current review status. Once the account is active, the banner is gone. # Creating Your Account > Get started with the ElasticPay merchant portal. ## Sign up Create an account at [dashboard.elasticpay.co](https://dashboard.elasticpay.co/users/sign_up) — email and password, no business details needed yet. You then complete a short form to set up your sandbox account. No approval is required to start testing. ## Sandbox vs live | Mode | Setup | Use for | | ------- | -------------------------- | ----------------------- | | Sandbox | Immediate | Development and testing | | Live | Requires compliance review | Real payments | Your sandbox account is fully functional with the same API and portal features as live. Payments made in sandbox mode are simulated and never charged to real cards. ## Your first API keys When your sandbox account is created, ElasticPay automatically generates one secret key and one publishable key for it. Find them under **Developer → API keys**, or go straight to [dashboard.elasticpay.co/developers](https://dashboard.elasticpay.co/developers). * **Secret key** (`sk_sandbox_...`) — use on your server to create and confirm payment intents * **Publishable key** (`pk_sandbox_...`) — use in the browser to initialize the widget Caution The full secret key is shown only once, immediately after creation. Copy it and store it securely — you cannot retrieve it again, only revoke and replace it. ## Next steps * [Quick Start](/api/getting-started/quick-start) — create your first payment in minutes * [Account Structure](/portal/getting-started/account-structure) — understand organisations, billers, and modes * [API Keys](/portal/accounts/api-keys) — manage and rotate your keys # Creating Plans > Create new payment plans for your customers. ## Navigate to Payment Plans → New From the sidebar, go to **Payment Plans** and click **New Plan**. Alternatively, create a plan from a customer’s detail page — it pre-fills the customer field. ## Required fields | Field | Notes | | ----------------- | -------------------------------------------------------- | | Customer | The customer to bill | | Name | Internal name for the plan (e.g. “Monthly subscription”) | | Start date | Date of the first payment | | Frequency period | Number (e.g. `1`, `2`) | | Frequency type | `W` (weekly) or `M` (monthly) | | Recurring amount | Amount — min $2.00, max $1,000.00 | | Failure behaviour | What to do when a payment fails (see below) | ## Optional fields | Field | Notes | | -------------------- | ---------------------------------------------------------- | | Category | Internal grouping label | | First payment date | If the first payment falls on a different date than start | | First amount | If the first payment should be a different amount | | Total amount | Set a fixed total — plan closes automatically when reached | | Until further notice | Open-ended plans with no total amount | ## Failure behaviour options | Behaviour | Code | Description | | ---------- | ------------ | ------------------------------------------------- | | Add to end | `NEW_AT_END` | Missed payment added as an extra at end of plan | | Retry | `TRY_AGAIN` | Payment retried after a delay (default: 3 days) | | Double up | `DOUBLE_UP` | Next scheduled payment includes the failed amount | | Do nothing | `DO_NOTHING` | Total reduced; failure recorded; no catchup | `TRY_AGAIN` works well for most plans — it recovers missed payments without extending the end date. Use `DO_NOTHING` when partial collection is acceptable and you don’t want to chase failures. ## Activating the plan Plans are created in `draft` state. Click **Activate** to begin scheduling payments. When a plan is created through the customer flow with a payment method still to be collected, you don’t need to activate it manually — the plan auto-activates once the customer’s payment method is successfully collected. Tip To set up a payment method at the same time as creating the plan, use the customer creation flow with the **Set up payment plan** toggle — it guides you through both steps in one sequence. # Handling Failures > Manage failed payments within a payment plan. ## What happens on failure When a scheduled payment fails: 1. The `payment_intent.failed` webhook event fires 2. The failure code is recorded on the payment intent 3. The plan’s `failure_count` is incremented 4. The configured failure behaviour is applied ## Failure behaviours ### `NEW_AT_END` — Add to end The missed payment is added as an extra payment at the end of the plan schedule. The total number of payments increases by one; the plan end date extends. Best for: Fixed-total plans where full collection is required. ### `TRY_AGAIN` — Retry The failed payment is retried after a delay (default: 3 days). If the retry also fails, the behaviour is applied again. Best for: Most plans — gives the customer time to resolve a temporary card issue. ### `DOUBLE_UP` — Double up The next scheduled payment is doubled to include the failed amount. No extra payment is added to the end of the schedule. Best for: Plans where extending the term is not desirable. ### `DO_NOTHING` — Do nothing The failure is recorded, the total amount is reduced by the failed amount, and no catchup occurs. The plan continues on its normal schedule. Best for: Plans where partial collection is acceptable. ## Customer on hold After a configurable number of consecutive failures, the customer is placed on `hold` status. This pauses payment processing across all of the customer’s plans until a new payment method is provided. ## Payment method recovery To recover a customer from `hold`: 1. Open the customer or plan detail page 2. Click **Open Recovery Setup Intent** 3. A recovery email is sent to the customer with a link to update their card 4. The customer enters a new card via the hosted widget 5. On success, the new card is saved, hold status is cleared, and suspended plans resume automatically ### Resending the recovery email If the customer did not receive the email or the link expired, click **Resend Recovery Email** on the customer or plan detail page. ## Manual override For one-off situations where automated recovery is not appropriate: 1. **Suspend** the plan 2. Process the payment manually (via the virtual terminal or a direct payment intent) 3. Apply a **Balance Adjustment** if needed to correct the collected total 4. **Resume** the plan # Managing Plans > View and manage active payment plans. ## Plan detail view The plan detail page shows: * **Schedule** — upcoming payment dates and amounts * **Upcoming intents** — payment intents scheduled but not yet processed * **Collected vs total** — how much has been collected against the total (if set) * **Failure count** — number of consecutive payment failures * **Activity log** — state changes, edits, and payment outcomes ## State actions | Action | From state | To state | Effect | | ---------- | ------------------- | ----------- | ---------------------------------------------------- | | Activate | `draft`, `inactive` | `active` | Begins scheduling future payments | | Deactivate | `active` | `inactive` | Stops scheduling; deletes unprocessed future intents | | Suspend | `active` | `suspended` | Temporary hold — preserves upcoming intents | | Resume | `suspended` | `active` | Resumes scheduling | | Close | Any | `closed` | Permanently ends the plan | Caution Deactivating a plan deletes all unprocessed future payment intents. These are not recoverable. To pause temporarily without losing the schedule, use **Suspend** instead. ## Editing a plan The following can be changed on an active or inactive plan: * Name and category * Recurring amount (takes effect from the next scheduled payment) * First amount (if the first payment hasn’t been processed yet) * Total amount * Failure behaviour After editing amounts, the future payment schedule is recalculated automatically. ## Balance adjustments To manually correct the collected or remaining balance (e.g. after an offline payment or write-off), use **Balance Adjustment** on the plan detail page. Enter a positive or negative amount and a reason — the adjustment is logged in the activity trail. ## Payment adjustments To change the amount for a specific scheduled payment date without affecting all future payments, use **Payment Adjustment**. Select the date from the upcoming schedule and enter the new amount for that payment only. # Understanding Payment Plans > Learn how payment plans work in ElasticPay. ## What is a payment plan A payment plan is a schedule of recurring payments for a customer. The portal’s scheduler automatically creates payment intents for each upcoming payment date and processes them using the customer’s saved payment method. Payment plans are created and managed in the portal. The API handles the individual payment intents that the scheduler creates. ## Frequency options | Frequency type | Code | Example | | -------------- | ---- | ----------- | | Weekly | `W` | Every week | | Monthly | `M` | Every month | Set a `period` to change the interval — period `2` with type `W` means every 2 weeks; period `3` with type `M` means every 3 months. ## Amount structure | Field | Description | | ------------------------ | ----------------------------------------------------------------------------------- | | `recurring_amount_cents` | Amount for each scheduled payment (min 200, max 100,000 cents) | | `first_amount_cents` | Optional different amount for the first payment | | `total_amount_cents` | Optional fixed total — plan closes automatically when collected amount reaches this | | `until_further_notice` | `true` for open-ended plans with no total amount | If neither `total_amount_cents` nor `until_further_notice` is set, the plan must be closed manually. ## Plan states | State | Description | Payments scheduled? | | ----------- | --------------------------------------------- | ------------------- | | `draft` | Created but not activated | No | | `active` | Running and scheduling payments | Yes | | `inactive` | Paused — future unprocessed intents deleted | No | | `suspended` | Temporary hold (e.g. payment method recovery) | No | | `closed` | Permanently ended | No | Activate a `draft` plan to begin scheduling. A `draft` plan also activates automatically when a payment method is collected via the customer-creation or **Collect Payment Details** flow. A plan in any state except `closed` can be closed. ## How payments are created The scheduler creates payment intents up to 366 days in advance. Each intent has a `process_at` timestamp for its scheduled date. On that date: 1. The intent is submitted to `PaymentIntentDO` in worker-pay 2. worker-pay processes the payment using the customer’s saved payment instrument 3. A `payment_intent.succeeded` or `payment_intent.failed` event is emitted 4. The outcome syncs back to the payment intent record in the portal `PaymentIntentDO` (a Cloudflare Durable Object) is the source of truth for processing state. The portal’s `PaymentIntent` reflects the outcome after sync. # Test Clocks > Simulate time in your sandbox from the dashboard. Test clocks let you fast-forward time for a set of sandbox customers so you can watch payment plans renew, retries fire, and links expire without waiting for real days to pass. They’re available for **sandbox accounts only**. ## Where to find them Go to **Developer → Test Clocks** in a sandbox account. From there you can: * **Create a clock** with a name and a starting simulated time * **View** each clock’s status and advance history * **Advance** a clock with the date/time picker — it defaults to one day forward, and the maximum step depends on the billing cycles attached (advancing further just takes multiple steps) * **Delete** a clock ## Attaching customers Customers join a clock **at creation**: on the **New Customer** form, choose the clock in the **Test Clock** field. The field only appears for sandbox accounts, and the association can’t be changed later. Customers on a clock are kept out of your normal customer list — filter by the clock to see them. ## Things to know * A clock lives for **30 days**, then it and all of its customers (and their plans and payments) are deleted permanently * Up to **3 customers per clock** and **3 active clocks** at a time * Clocks only move **forward** — to start over, delete and recreate * Not everything moves with the clock — settlement and BECS collection outcomes stay on real time Integrating via the API instead? See the [Test Clocks developer guide](/api/guides/test-clocks). # Common Issues > Solutions to frequently encountered problems in the ElasticPay portal. ## Payment intent stuck in `requires_payment_method` The card was not tokenized, or confirm was never called. Check: * The widget’s `onTokenize` callback fired and the `pm_xxx` was sent to your server * The confirm endpoint was called with the `payment_method` field set * No errors were returned from the confirm call ## Webhook not received 1. **Check the endpoint is reachable** — it must be publicly resolvable over HTTPS (not `localhost`) and return a 2xx quickly. Endpoints are configured by ElasticPay, so if you are unsure which URL is registered, ask support to confirm it 2. **Check account status** — a suspended or deactivated account stops webhook delivery 3. **Check `livemode`** — sandbox events only go to sandbox-mode endpoints; live events go to live-mode endpoints 4. **Check the delivery status** — open the payment intent, payment plan, or customer the event relates to and look at the **Events** panel. Each row shows the event type and the latest delivery status with an attempt count. `No deliveries` means no subscription matched the event ## `refund_amount_exceeds_available` error The sum of all refund amounts for this payment intent would exceed the original payment amount. Check existing refunds before issuing another: ```bash curl https://staging-api.elasticpay.co/api/v1/payment_intents/pi_0abc123/refunds \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` Sum the `amount` fields of all `succeeded` refunds. The remaining refundable amount is `original_amount - total_refunded`. ## Customer on hold unexpectedly A customer is on `hold` either because they were created with a payment method still to be collected (*waiting for payment method*), or because consecutive payment failures triggered recovery. Collecting a valid payment method clears the hold automatically — you no longer need to flip the status manually. To investigate: 1. Open the customer detail page and check the **Activity Log** for recent status changes 2. If they’re awaiting a method, use **Collect Payment Details** (or resend the email link) to capture one 3. For a recovery hold, review the most recent failed payment intents for failure codes and use **Open Recovery Setup Intent** to send the customer a link to update their payment method ## API key rejected (401) Causes: * Key has been revoked — check **Developer → API keys** for revocation date * Key has expired — check the `expires_at` field * Mode mismatch — sandbox key used against a live endpoint, or vice versa * Key copied with leading/trailing whitespace — verify the key value exactly ## Plan not scheduling * **Plan is in `draft` state** — click **Activate** to begin scheduling. If the plan was created through the deferred payment-method flow, it activates automatically once the method is collected; a plan still in `draft` usually means the setup hasn’t succeeded yet * **Customer is not `active`** — a customer on `hold` or `cancelled` does not receive new scheduled payments * **Plan is `inactive` or `closed`** — reactivate or create a new plan * **Start date is in the future** — the scheduler creates intents up to 366 days ahead from today # Getting Help > How to get support for ElasticPay. ## Documentation * [API Reference](/api-reference) — full endpoint reference with request/response schemas * [Error Codes](/api/resources/error-codes) — all API error types and codes * [Payment Lifecycle](/api/concepts/payment-lifecycle) — state transitions explained * [Webhooks](/api/guides/webhooks) — event format and signature verification ## Debugging checklist Before contacting support: 1. **API key mode** — is your key sandbox or live? Does it match the endpoint? 2. **Request headers** — `Authorization: Bearer sk_...` and `Content-Type: application/json` 3. **Error response body** — read the full `error.message` and `error.code` fields 4. **Webhook delivery** — the **Events** panel on a payment intent, payment plan, or customer shows the latest delivery status and attempt count for that record’s events 5. **Activity logs** — customer and plan detail pages show a full change history ## Support Contact the ElasticPay support team for account-level issues, compliance questions, and integration help not covered in the documentation. Tip When contacting support, include the relevant `payment_intent_id` (`pi_xxx`), `customer_id` (`cus_xxx`), or `event_id` (`evt_xxx`) from your logs. This allows the support team to locate the exact records without delay.