One-time Payment
End-to-end recipe: collect a card, charge it once, confirm via webhook.
What you’ll build
A flow that takes a customer’s card details (in sandbox: a test token), creates an EPD customer, attaches the card, and runs a single charge. The charge result is delivered both synchronously in the response and asynchronously via webhook.
Use this recipe for: one-off purchases, top-ups, donations, services billed only once.
Prerequisites
- A sandbox API key (see Quickstart).
- A webhook endpoint registered for
order.succeededandorder.failed(see Webhooks). - A way to capture the card: EPD Elements in production, or for sandbox testing without a browser, Inbound Card Capture with a sandbox test card number.
- A product to charge against: orders charge against products, not free-form amounts. Create one with
POST /v1/productsand save itsidas$PRODUCT_ID.
Steps
EPD requires a first name, last name, and a phone number on every customer (in international format with a leading + and country code, e.g. +14155551234). Email is required too.
curl https://api.epd.com/v1/customers \
-H "Authorization: Bearer $EPD_KEY" \
-H "epd-version: 2026-02-11" \
-H "X-EPD-Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"first_name": "Jane",
"last_name": "Doe",
"phone": "+14155551234"
}'
Capture the card with EPD Elements and attach the resulting card_token.
curl https://api.epd.com/v1/customers/$CUSTOMER_ID/payment_methods \
-H "Authorization: Bearer $EPD_KEY" \
-H "epd-version: 2026-02-11" \
-H "X-EPD-Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "card_token": "cct_…", "set_as_default": true }'
The response returns the new payment method; save its id as $PM_ID for the next step.
Testing without a browser? Use Inbound Card Capture with a sandbox test card number (e.g. 4111 1111 1111 1111) instead: it captures and attaches the card server-to-server, no browser required.
EPD creates the order and runs the charge in a single call. The response tells you whether the charge succeeded.
curl https://api.epd.com/v1/orders \
-H "Authorization: Bearer $EPD_KEY" \
-H "epd-version: 2026-02-11" \
-H "X-EPD-Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "'"$CUSTOMER_ID"'",
"payment_method_id": "'"$PM_ID"'",
"items": [
{ "product_id": "'"$PRODUCT_ID"'", "quantity": 1 }
],
"metadata": { "internal_order_id": "ORD-1234" }
}'
Listen for order.succeeded (or order.failed) on your webhook endpoint and update your own database. The webhook is the source of truth: it survives client-side disconnects and double-submits.
Do not mark the user-facing order as paid based on the synchronous API response alone. A network drop after EPD captured the charge but before your code saw the response would leave you out of sync. Always reconcile against the webhook.
What can go wrong
A decline is not an error envelope: the order comes back 201 with status: "failed" and a failure_code. A genuine gateway or environment error does return an error object.
| Failure | What you’ll see | Best response |
|---|---|---|
| Card declined | status: "failed", failure_code: "processor_declined" | Show the card was declined; let the user try a different card. |
| Insufficient funds | status: "failed", failure_code: "insufficient_funds" | Same as above; some users will retry on payday. |
| Expired card | status: "failed", failure_code: "expired_card" | Prompt for a new card. |
| Gateway transient error | error.code = gateway_error (5xx) | Retry with the same X-EPD-Idempotency-Key. |
| Network timeout (no response) | Unknown | Retry with the same idempotency key: EPD dedupes. |
card_token captured with a mismatched key environment | error.code = environment_mismatch | Make sure the publishable key used to capture the card and the secret key used to attach it are both sandbox or both live. |