---
updatedAt: 2026-09-03T09:02:32.000Z
agentTools:
  projectIndex: https://docs.urbanpayx.com/llms.txt
---

# Bulk Payment Links

Create many payment links at once from a CSV/XLSX file or JSON rows: preview, submit, then poll the batch for each link.

Create many payment links in one go from a spreadsheet or from JSON rows.
The flow has three steps: preview (validate, nothing is created), commit
(queue a background batch), and polling the batch for each row's link.

Bulk links use the same permission and account setting as single payment
links. If payment links are disabled for your account every endpoint returns
`403`.

## Authentication

All endpoints use your standard API bearer token:

```
Authorization: Bearer <token>
```

## Base path

```
/api/v1/checkout/paylink/bulk
```

## 1. Templates

### GET /template.csv

Header-only CSV with the supported columns.

### GET /template.xlsx

Same columns as an Excel workbook. The `project` column carries a dropdown
of your active projects and `send_email` a yes/no dropdown.

Columns:

| Column        | Required | Description                                                                                                                                                           |
| ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email`       | yes      | Payer email. Matched against your existing users.                                                                                                                     |
| `amount`      | yes      | EUR amount. `1234.56`, `1234,56`, `1.234,56` and `1,234.56` are all accepted. A single separator followed by exactly three digits (`1,234`) is rejected as ambiguous. |
| `description` | no       | Falls back to `default_description`.                                                                                                                                  |
| `project`     | no       | Project name as shown in the template dropdown. Falls back to `default_project_id`.                                                                                   |
| `full_name`   | no       | Used only when a new user is created for the email.                                                                                                                   |
| `send_email`  | no       | `yes` / `no`. Falls back to `default_send_email`.                                                                                                                     |

Header names are matched case-insensitively. Extra columns are ignored.
Up to 500 data rows and 5 MB per file.

## 2. Preview

### POST /preview/file

`multipart/form-data` with the file in `file` and optional form fields
`default_project_id`, `default_description`, `default_send_email`
(default `true`) and `auto_create_users` (default `true`).

### POST /preview

JSON body:

```json
{
  "rows": [
    {"source_row": 2, "email": "ana@example.com", "amount": "1.234,56", "description": "Rent September"},
    {"source_row": 3, "email": "luis@example.com", "amount": 900, "project": "Calle Mayor 12", "send_email": "no"}
  ],
  "default_project_id": "prj_...",
  "default_description": "Rent September",
  "default_send_email": true,
  "auto_create_users": true
}
```

Rows may give `project_id` (takes precedence) or `project` (a name). Both
preview endpoints return the same shape and create nothing:

```json
{
  "rows": [
    {
      "source_row": 2,
      "email": "ana@example.com",
      "full_name": null,
      "amount": "1234.56",
      "currency": "EUR",
      "description": "Rent September",
      "project_id": "prj_...",
      "project_name": "Calle Mayor 12",
      "send_email": true,
      "user_id": "usr_...",
      "user_exists": true,
      "errors": [],
      "warnings": [{"code": "duplicate_row", "message": "...", "duplicate_of": 7}]
    }
  ],
  "summary": {"total": 120, "valid": 117, "with_errors": 3, "with_warnings": 8}
}
```

Rows with `errors` cannot be committed. Rows with `warnings` can, once you
acknowledge them.

Error codes (per row, in `errors[].code`):
`invalid_email`, `invalid_amount`, `ambiguous_amount`, `amount_out_of_range`,
`amount_too_many_decimals`, `description_required`, `description_too_long`,
`project_required`, `project_not_found`, `project_ambiguous`, `project_frozen`,
`full_name_too_long`, `invalid_send_email`, `user_not_found` (only when
`auto_create_users` is false).

Warning codes (in `warnings[].code`): `duplicate_row` (same email, amount and
description as an earlier row; `duplicate_of` gives that row), and
`user_will_be_created` (no user exists for the email yet).

Whole-file problems return `422` with `{"detail": {"code": ..., "message": ...}}`:
`unsupported_file_type`, `file_too_large`, `missing_required_columns`,
`too_many_rows`, `empty_file`, `unreadable_file`.

## 3. Commit

### POST /

Requires the `X-Idempotency-Key` header. Body is the preview JSON body plus:

| Field                  | Type     | Required | Description                              |
| ---------------------- | -------- | -------- | ---------------------------------------- |
| `default_project_id`   | string   | yes      | Project for rows without one             |
| `acknowledge_warnings` | boolean  | no       | Must be `true` when any row has warnings |
| `intent_expires_at`    | datetime | no       | Hard expiry applied to every link        |
| `source_filename`      | string   | no       | Shown in the batch list                  |

Responses:

* `202` batch created (body below, status `queued`)
* `200` the idempotency key was already used; the existing batch is returned
* `400` empty idempotency key
* `409` `warnings_not_acknowledged` (the offending rows are in `detail.rows`), or
  `batch_already_running` (one active batch per account)
* `422` `row_errors` with the offending rows in `detail.rows`; nothing is created

Users are created for unknown emails only when the batch runs, and only when
`auto_create_users` is true. No verification is started for them.

## 4. Batches

### GET /

Paginated list of batches (`page`, `page_size`), newest first.

### GET /{batch_id}

```json
{
  "id": "batch_...",
  "status": "processing",
  "source_filename": "rent-september.xlsx",
  "total_rows": 120,
  "created_count": 40,
  "failed_count": 1,
  "canceled_count": 0,
  "default_project_id": "prj_...",
  "default_description": "Rent September",
  "default_send_email": true,
  "auto_create_users": true,
  "intent_expires_at": null,
  "created_by_email": "ops@example.com",
  "created_at": "2026-09-02T10:00:00Z",
  "started_at": "2026-09-02T10:00:05Z",
  "completed_at": null,
  "cancel_requested_at": null,
  "rows": [
    {
      "id": "row_...",
      "sequence": 1,
      "source_row": 2,
      "email": "ana@example.com",
      "full_name": null,
      "amount": "1234.56",
      "currency": "EUR",
      "description": "Rent September",
      "project_id": "prj_...",
      "project_name": "Calle Mayor 12",
      "send_email": true,
      "status": "created",
      "attempt": 0,
      "user_id": "usr_...",
      "user_created": false,
      "transaction_id": "txn_...",
      "payment_link": "https://.../pay/txn_...",
      "error_message": null,
      "processed_at": "2026-09-02T10:00:06Z"
    }
  ]
}
```

Batch status: `queued`, `processing`, `completed`, `partial_failed`, `failed`,
`canceled`. Row status: `pending`, `created`, `failed`, `canceled`.
`created_count`, `failed_count` and `canceled_count` are updated when the batch
finishes; while it runs, count the rows by status.

Poll every few seconds until the batch status is terminal. Each `created` row
carries a `payment_link` and a `transaction_id` you can follow with the
Transactions API.

### GET /{batch_id}/results.csv

The rows as CSV: `source_row, email, full_name, amount, currency, description,
project, send_email, status, payment_link, transaction_id, error`.

### POST /{batch_id}/retry

Puts the `failed` rows of a `partial_failed` or `failed` batch back in the
queue. Rows already `created` are never touched. `409 batch_not_retryable`
otherwise.

### POST /{batch_id}/cancel

Asks a `queued` or `processing` batch to stop. Rows not processed yet end as
`canceled`; links already created stay valid. `409 batch_not_cancelable`
otherwise.