What you’ll build

A subscription that bills the same customer on a recurring schedule (e.g. $29.99 every month). EPD handles the renewal, retry, and dunning for you. Your job is to react to lifecycle events.

Use this recipe for: SaaS plans, memberships, recurring services, scheduled top-ups.

The model

EPD separates the thing being sold (a product) from the price and cadence it is sold at (a plan), then ties a customer to a plan via a subscription.

Product   ──►   Plan   ──►   Subscription
(catalog)       (pricing)    (one customer's contract)

Prerequisites

  • A customer with at least one attached payment method (see one-time payment recipe for the steps).
  • A registered webhook endpoint for subscription.* events.
  • A plan created in the Merchant Portal. Heads up on naming: what the API calls a plan, the portal calls a Subscription Template (left sidebar → Subscription TemplatesCreate Template). Plans are read-only via the API today, so create them in the portal and reference them by id here.

Steps

Create the product and plan in the portal

Plans are created in the portal, not the API (the API reads them but can’t write them). One naming quirk to get past first: what the API calls a plan, the portal calls a Subscription Template. In the Merchant Portal, create your product (“Pro Plan”), then open Subscription Templates in the left sidebar, hit Create Template, attach that product, and set the price (amount in cents) and billing interval (month or day).

Now you need that plan’s id to subscribe customers to it, and this is where people get stuck: the portal shows the plan code you gave it (the short @-prefixed handle), which is not the plan_id the API expects. Ask the API for the real id:

curl https://api.epd.com/v1/plans \
  -H "Authorization: Bearer $EPD_KEY" \
  -H "epd-version: 2026-02-11"

Every plan comes back with an id (a UUID, this is your plan_id) next to its name and plan_code, so you can pick out the one you just made. Keep that id for the next step.

Create the subscription

Bind the customer, the plan, and the payment method together. The billing_cycle block tells EPD how often to charge.

curl https://api.epd.com/v1/subscriptions \
  -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": "550e8400-e29b-41d4-a716-446655440000",
    "plan_id": "6ba7b814-9dad-11d1-80b4-00c04fd430d0",
    "payment_method_id": "6ba7b815-9dad-11d1-80b4-00c04fd430c8",
    "billing_cycle": {
      "interval": "month",
      "interval_count": 1,
      "anchor_day": "signup_date"
    }
  }'

The first charge runs immediately. anchor_day controls when each renewal hits: leave it as "signup_date" (the default) to bill on whatever day they signed up, or set it to a number from 1 to 31 (for example 15) to bill on a fixed day every month.

React to lifecycle webhooks

Each renewal cycle fires events you should handle in your app: see the table below.

Free trials are not currently supported on the API. If you need a trial, charge a $0 plan in the portal, or implement the trial period in your own application logic and switch to the paid plan when it ends.

Viewing subscriptions in the portal

In the Merchant Portal, subscriptions live under the template they belong to. Open Subscription Templates in the left sidebar, select the template, then open its Subscriptions tab to see every customer subscribed to it. Click a subscriber and a right-side drawer slides in with their billing schedule, payment method, and shipping info, where you can manage the subscription (for example, swap the payment method or shipping address). The template’s Orders tab lists the charges those subscriptions have generated.

Lifecycle events

EventWhen it firesWhat you typically do
subscription.createdSubscription created: status is active (first charge cleared) or failed (first charge declined)Provision access when active; on failed the same charge also fires order.failed
subscription.chargedEach successful renewal chargeExtend access through the next period
subscription.updatedPayment method, shipping, or billing cycle changedUpdate your local copy
subscription.billing.updatedBilling cycle (interval/anchor_day) changedReflect the new schedule in your UI
subscription.canceledSubscription canceled (always immediate)Revoke access

Every charge (the first charge at signup and every renewal) is a regular order, so a decline fires order.failed. There is no dedicated subscription.payment_failed event today, so use a single order.failed handler for both. The payload carries subscription_id, subscription_cycle, attempt_count (how many charge attempts have failed), and next_retry_at. On a renewal decline, subscription_cycle is set and next_retry_at is when EPD’s dunning retries next. On a first-charge decline there is no cycle yet, so subscription_cycle is null and next_retry_at is null. EPD does not auto-retry the very first charge, so collect a new card or call POST /v1/orders/{id}/retry yourself. (subscription.created fires alongside with status: "failed" as the lifecycle mirror of the same event.)

Don’t want to wait for the dunning cron? Call POST /v1/orders/{id}/retry on the failed renewal order to re-charge the card on file right away. On success EPD reconciles the subscription cycle, so the scheduled dunning retry won’t charge the customer a second time.

Canceling a subscription

Cancellation is always immediate: there is no at_period_end flag today. Use DELETE /v1/subscriptions/:id with no body:

curl -X DELETE https://api.epd.com/v1/subscriptions/$SUBSCRIPTION_ID \
  -H "Authorization: Bearer $EPD_KEY" \
  -H "epd-version: 2026-02-11" \
  -H "X-EPD-Idempotency-Key: $(uuidgen)"

If you want the customer to keep access until the end of the period they’ve paid for, hold off on calling cancel and run it from your own scheduler (a cron job, a queued task, or whatever your app already uses) on the period-end date.

Pause and resume

POST /v1/subscriptions/:id/pause and POST /v1/subscriptions/:id/resume exist but currently return HTTP 501 Not Implemented with error.code = not_implemented. The feature is on the roadmap. To pause-and-resume today, cancel the subscription and create a fresh one when the customer comes back.

Common pitfalls