---
title: Collect recurring payments
slug: collect-recurring-payments
docTags: 
createdAt: 2026-09-29T20:21:41.366Z
---

Collect the same payment from a customer on a schedule, weekly, monthly, quarterly or yearly, without creating each debit yourself.

Use recurring plans for subscriptions, memberships, rent, instalments, or any charge that repeats. Each scheduled run creates a debit against the customer's payment method and deposits the funds into your bank account.

***

## Prerequisites

- [Getting your API Key](docId:_zgmIm7QKrrAoX9JjaQrL)
- Your [Getting your bank account ID](docId\:aEx0SSFndR_XgalXG2_ZF) where funds will be deposited
- A [Create a contact](docId\:BzwF0wyOoqBKGSFuGEnH0) for the customer

The customer pays with one of:

- A [Tokenize a contact credit card](docId:_5zW0FS71q4rn5rY5IFPj) on their contact
- A bank account on their contact, plus a signed Pre-Authorized Debit (PAD) agreement, which these endpoints call a **mandate**
- Nothing yet: Jetpay gives you a link where the customer adds a payment method and accepts the plan themselves

***

## Flow overview

```mermaid
sequenceDiagram
    autonumber
    participant Platform
    participant Jetpay
    participant Customer

    Platform->>Jetpay: POST /recurring-plans
    alt Card on file
        Jetpay-->>Platform: Plan created (status: active)
    else Bank account
        Jetpay-->>Platform: Plan created (status: pending_mandate_upload)
        Platform->>Jetpay: PUT /recurring-plans/{id}/mandate
        Jetpay-->>Platform: Plan updated (status: pending_mandate_review)
    else No payment method
        Jetpay-->>Platform: Plan created (status: pending_customer_acceptance)
        Platform->>Customer: Share the acceptance link
        Customer->>Jetpay: Add payment method and accept
    end
    Jetpay-->>Platform: Webhook (payments.recurringPlans.collectionEnabled)

    loop Every scheduled date
        Jetpay->>Jetpay: Create debit
        Jetpay-->>Platform: Webhook (payments.transactions.debits.*)
    end
```

***

## Implementation details

### Create a plan

Endpoint: [`POST /recurring-plans`](https://extapi.jetpay.baselinepayments.com/docs#tag/Recurring-Plans-\(v0\)/operation/create_recurring_plan_recurring_plans_post)

This example charges a card on file every month, starting on the date you choose.

:::CodeblockTabs
```curl
curl https://extapi.demo.jetpay.baselinepayments.com/recurring-plans \
  -X POST \
  -H "Authorization: Bearer <YOUR_API_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_id": "contact_123",
    "credit_card_id": "cc_456",
    "to_bank_account_id": "<your_bank_account_id>",
    "amount": 49.99,
    "frequency": "monthly",
    "start_date": "2026-10-15T00:00:00Z",
    "statement": "Monthly membership"
  }'
```

```python
import requests

url = "https://extapi.demo.jetpay.baselinepayments.com/recurring-plans"

headers = {
    "Authorization": "Bearer <YOUR_API_TOKEN>",
    "Content-Type": "application/json"
}

payload = {
    "contact_id": "contact_123",
    "credit_card_id": "cc_456",
    "to_bank_account_id": "<your_bank_account_id>",
    "amount": 49.99,
    "frequency": "monthly",
    "start_date": "2026-10-15T00:00:00Z",
    "statement": "Monthly membership"
}

response = requests.post(url, json=payload, headers=headers)

print(response.status_code)
print(response.json())
```

```nodejs
const fetch = require("node-fetch");

const url = "https://extapi.demo.jetpay.baselinepayments.com/recurring-plans";

const payload = {
  contact_id: "contact_123",
  credit_card_id: "cc_456",
  to_bank_account_id: "<your_bank_account_id>",
  amount: 49.99,
  frequency: "monthly",
  start_date: "2026-10-15T00:00:00Z",
  statement: "Monthly membership"
};

fetch(url, {
  method: "POST",
  headers: {
    "Authorization": "Bearer <YOUR_API_TOKEN>",
    "Content-Type": "application/json"
  },
  body: JSON.stringify(payload)
})
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));
```
:::

To collect from a bank account instead, send `from_bank_account_id` in place of `credit_card_id`. To let the customer choose, leave both out.

:::hint{type="info"}
If the amount will change over time, add `"plan_type": "variable"`. Otherwise the plan is `fixed` and always collects the same amount.
:::

### Handle the response

A new plan returns HTTP `201`. A card plan is `active` right away:

```json
{
  "identifier": "plan_789",
  "amount": 49.99,
  "status": "active",
  "expiry_reason": null,
  "plan_type": "fixed",
  "frequency": "monthly",
  "contact_id": "contact_123",
  "to_bank_account_id": "your_bank_account_id",
  "from_bank_account_id": null,
  "customer_credit_card_id": "cc_456",
  "start_date": "2026-10-15T00:00:00Z",
  "end_date": null,
  "statement": "Monthly membership",
  "note": null,
  "link": "<acceptance_url>",
  "created_at": "2026-09-29T14:02:11Z"
}
```

Store the returned `identifier`. You need it to manage the plan later.

A bank account plan comes back as `pending_mandate_upload`, and a plan with no payment method comes back as `pending_customer_acceptance`. The next two steps cover each case.

### Upload the mandate (bank account plans)

Endpoint: [`PUT /recurring-plans/{identifier}/mandate`](https://extapi.jetpay.baselinepayments.com/docs#tag/Recurring-Plans-\(v0\)/operation/upload_mandate_recurring_plans__identifier__mandate_put)

Upload the PAD agreement your customer signed, as a PDF.

:::CodeblockTabs
```curl
curl https://extapi.demo.jetpay.baselinepayments.com/recurring-plans/plan_789/mandate \
  -X PUT \
  -H "Authorization: Bearer <YOUR_API_TOKEN>" \
  -F "file=@signed-pad-agreement.pdf;type=application/pdf"
```

```python
import requests

url = "https://extapi.demo.jetpay.baselinepayments.com/recurring-plans/plan_789/mandate"

headers = {
    "Authorization": "Bearer <YOUR_API_TOKEN>"
}

with open("signed-pad-agreement.pdf", "rb") as f:
    files = {"file": ("signed-pad-agreement.pdf", f, "application/pdf")}
    response = requests.put(url, files=files, headers=headers)

print(response.status_code)
print(response.json())
```

```nodejs
const fetch = require("node-fetch");
const FormData = require("form-data");
const fs = require("fs");

const url = "https://extapi.demo.jetpay.baselinepayments.com/recurring-plans/plan_789/mandate";

const form = new FormData();
form.append("file", fs.createReadStream("signed-pad-agreement.pdf"), {
  contentType: "application/pdf"
});

fetch(url, {
  method: "PUT",
  headers: {
    "Authorization": "Bearer <YOUR_API_TOKEN>",
    ...form.getHeaders()
  },
  body: form
})
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));
```
:::

The plan moves to `pending_mandate_review` and can start collecting. Jetpay then reviews the mandate, and you receive `payments.recurringPlans.mandate.approved` or `payments.recurringPlans.mandate.rejected`.

### Share the acceptance link (no payment method)

Send the plan's `link` to your customer. Jetpay also emails it to them. On the hosted page, they add a card or bank account and accept the plan, and it becomes `active`.

The customer must accept before the plan's start date.

### Update a plan

Endpoint: [`PATCH /recurring-plans/{identifier}`](https://extapi.jetpay.baselinepayments.com/docs#tag/Recurring-Plans-\(v0\)/operation/update_recurring_plan_recurring_plans__identifier__patch)

Change the statement, note or end date on any plan, or the amount on a variable plan. Send only the fields you want to change.

:::CodeblockTabs
```curl
curl https://extapi.demo.jetpay.baselinepayments.com/recurring-plans/plan_789 \
  -X PATCH \
  -H "Authorization: Bearer <YOUR_API_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 59.99
  }'
```

```python
import requests

url = "https://extapi.demo.jetpay.baselinepayments.com/recurring-plans/plan_789"

headers = {
    "Authorization": "Bearer <YOUR_API_TOKEN>",
    "Content-Type": "application/json"
}

payload = {
    "amount": 59.99
}

response = requests.patch(url, json=payload, headers=headers)

print(response.status_code)
print(response.json())
```

```nodejs
const fetch = require("node-fetch");

const url = "https://extapi.demo.jetpay.baselinepayments.com/recurring-plans/plan_789";

const payload = {
  amount: 59.99
};

fetch(url, {
  method: "PATCH",
  headers: {
    "Authorization": "Bearer <YOUR_API_TOKEN>",
    "Content-Type": "application/json"
  },
  body: JSON.stringify(payload)
})
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));
```
:::

The change applies from the next payment onward.

### Change the payment method

Endpoint: [`POST /recurring-plans/{identifier}/payment-method`](https://extapi.jetpay.baselinepayments.com/docs#tag/Recurring-Plans-\(v0\)/operation/update_payment_method_recurring_plans__identifier__payment_method_post)

Swap the card or bank account on an active plan, for example when a customer's card expires.

:::CodeblockTabs
```curl
curl https://extapi.demo.jetpay.baselinepayments.com/recurring-plans/plan_789/payment-method \
  -X POST \
  -H "Authorization: Bearer <YOUR_API_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "credit_card_id": "cc_999"
  }'
```

```python
import requests

url = "https://extapi.demo.jetpay.baselinepayments.com/recurring-plans/plan_789/payment-method"

headers = {
    "Authorization": "Bearer <YOUR_API_TOKEN>",
    "Content-Type": "application/json"
}

payload = {
    "credit_card_id": "cc_999"
}

response = requests.post(url, json=payload, headers=headers)

print(response.status_code)
print(response.json())
```

```nodejs
const fetch = require("node-fetch");

const url = "https://extapi.demo.jetpay.baselinepayments.com/recurring-plans/plan_789/payment-method";

const payload = {
  credit_card_id: "cc_999"
};

fetch(url, {
  method: "POST",
  headers: {
    "Authorization": "Bearer <YOUR_API_TOKEN>",
    "Content-Type": "application/json"
  },
  body: JSON.stringify(payload)
})
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));
```
:::

A new card takes effect right away, on the same schedule. A new bank account puts the plan back to `pending_mandate_upload`, and payments pause until you upload a mandate for it.

### Cancel a plan

Endpoint: [`POST /recurring-plans/{identifier}/cancel`](https://extapi.jetpay.baselinepayments.com/docs#tag/Recurring-Plans-\(v0\)/operation/cancel_recurring_plan_recurring_plans__identifier__cancel_post)

Stop all future payments.

:::CodeblockTabs
```curl
curl https://extapi.demo.jetpay.baselinepayments.com/recurring-plans/plan_789/cancel \
  -X POST \
  -H "Authorization: Bearer <YOUR_API_TOKEN>"
```

```python
import requests

url = "https://extapi.demo.jetpay.baselinepayments.com/recurring-plans/plan_789/cancel"

headers = {
    "Authorization": "Bearer <YOUR_API_TOKEN>"
}

response = requests.post(url, headers=headers)

print(response.status_code)
print(response.json())
```

```nodejs
const fetch = require("node-fetch");

const url = "https://extapi.demo.jetpay.baselinepayments.com/recurring-plans/plan_789/cancel";

fetch(url, {
  method: "POST",
  headers: {
    "Authorization": "Bearer <YOUR_API_TOKEN>"
  }
})
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));
```
:::

The plan comes back with `"status": "cancelled"`. Cancelling is final.

### Find your plans

Use [`GET /recurring-plans`](https://extapi.jetpay.baselinepayments.com/docs#tag/Recurring-Plans-\(v0\)/operation/list_recurring_plans_recurring_plans_get) to list plans, filtered by contact, status or plan type. For example, this finds every plan waiting on a mandate:

```curl
curl "https://extapi.demo.jetpay.baselinepayments.com/recurring-plans?status=pending_mandate_upload" \
  -H "Authorization: Bearer <YOUR_API_TOKEN>"
```

To fetch one plan, use [`GET /recurring-plans/{identifier}`](https://extapi.jetpay.baselinepayments.com/docs#tag/Recurring-Plans-\(v0\)/operation/get_recurring_plan_recurring_plans__identifier__get).

### Track status updates via events/webhooks

As a plan moves through its lifecycle, an **Event** is created for each update. Each scheduled payment is a regular debit, so it also produces the usual debit events. The most efficient way to track these updates in your system is to register for webhooks. If you are not yet familiar with our webhooks system, please review the [Events and webhooks](docId:79a4CGVETAChOLkpJYd5C) guide.

```json
{
  "recurringPlanId": "plan_789",
  "status": "active",
  "planType": "fixed",
  "frequency": "monthly",
  "amount": "49.99",
  "startDate": "2026-10-15T00:00:00Z",
  "endDate": null,
  "expiryReason": null,
  "contactId": "contact_123",
  "toBankAccountId": "your_bank_account_id",
  "fromBankAccountId": null,
  "customerCreditCardId": "cc_456",
  "statement": "Monthly membership",
  "description": null
}
```

See the [Recurring Plans reference](https://extapi.jetpay.baselinepayments.com/docs#tag/Recurring-Plans-\(v0\)) for request fields and error codes, and the [Events reference](https://extapi.jetpay.baselinepayments.com/docs#section/Events-Reference) for every recurring plan event.

***

## Handling failures

**The plan can't be created.** Nothing was set up, so fix the cause and try again:

- `400`: the request is invalid. A common cause is a start date earlier than tomorrow.
- `404`: the contact, bank account or card wasn't found. The card or bank account must belong to the contact on the plan.
- `422`: a bank account isn't ready to use yet, or the payment provider rejected the customer's bank account.

**The request times out or returns a&#x20;**`5xx`**.** Creating a plan doesn't support an `Idempotency-Key`, so a retry can create a second plan. List the contact's plans first, and only create a new one if it isn't there.

**A scheduled payment fails.** You receive a debit event with the failed state, and Jetpay emails both you and the customer. The plan stays `active` and isn't retried automatically: the next attempt is the next scheduled date. Follow up with the customer, and swap the payment method if theirs is no longer valid.

**The mandate is rejected.** You receive `payments.recurringPlans.mandate.rejected` with a `reason` that is safe to show your customer. The plan goes back to `pending_mandate_upload` and stops collecting. Get a corrected mandate signed and upload it.

**The plan expires.** You receive `payments.recurringPlans.expired`. This happens when a plan never activates in time: the customer didn't accept, no mandate was uploaded, or the mandate wasn't reviewed in time (a delay on our side). An expired plan can't be reactivated, so create a new one.

***

## Testing

Use the demo environment to run each flow before going live:

1. Create a card plan starting tomorrow and confirm you receive `payments.recurringPlans.created` and `payments.recurringPlans.collectionEnabled`.
2. Create a bank account plan, upload any PDF as the mandate, and confirm you receive `payments.recurringPlans.mandate.uploaded`.
3. Create a plan with no payment method, open its `link`, and accept it as the customer would.
4. Update the amount on a variable plan and confirm `payments.recurringPlans.updated` shows the change.
5. Cancel a plan and confirm a second cancel is refused with `409`.

Payments run on the plan's schedule and can't be triggered early, so you won't see debit events before the start date.

***

## Best practices

- **Store the plan&#x20;**`identifier`**.** It's how you match plan events to your records.
- **Upload mandates promptly.** A plan only has until its second scheduled payment to get a mandate on file, and that deadline doesn't move if a mandate is rejected.
- **Use variable plans for changing amounts.** A fixed plan can't change its amount after creation.
- **Treat&#x20;**`collectionEnabled`**&#x20;as a state, not a one-off.** It fires again each time a plan becomes able to collect, for example after a new mandate is uploaded.
- **Watch the debit events.** Plan events tell you when a plan changes. Debit events tell you whether each payment succeeded.
- **Check before you retry a create.** List the contact's plans so you don't set up the same plan twice.

***

## FAQ

**When is the first payment collected?**
On the first scheduled date on or after the plan's start date. The start date has to be tomorrow or later.

**Can a bank account plan collect before its mandate is approved?**
Yes. Once the mandate is uploaded, the plan can collect while Jetpay reviews it. If the mandate is later rejected, future payments stop.

**When does a change to the amount take effect?**
From the next payment that hasn't been created yet. Payments already created aren't changed.

**Does cancelling stop a payment that's already in progress?**
No. Cancelling stops future payments only.

**What happens when a plan reaches its end date?**
It stops collecting. Its status stays `active`.

**Can I restart a cancelled or expired plan?**
No. Create a new plan for the customer.

**Does my customer get notified?**
Yes. Jetpay emails the customer when the plan is set up, a reminder before each payment, after each payment, and when the plan is cancelled, unless customer emails are turned off for your account.

**Is the customer surcharge applied to card plans?**
Yes. If your account charges a customer surcharge, it's added to each card payment. Bank account payments have no surcharge.
