# Buy credits by card

> POST /v1/account/purchases. Start a card payment in Stripe Checkout for credits at the per-contact scale (the size of the line picks its band), the 40,000 credit pack, or a quote's purchase option line by line.

Source: https://emailmovers.com/mydatahub/docs/reference/rest/create-credit-purchase

`POST /v1/account/purchases`

Start a card payment in Stripe Checkout for credits at the per-contact scale (the size of the line picks its band), the 40,000 credit pack, or a quote's purchase option line by line. Prices exclude VAT; 20 percent UK VAT is added at checkout, which takes cards only (Apple Pay and Google Pay through the card method) and a UK billing address. Answers with the checkout_url to send the person to. The credits are added once, when Stripe says the payment is paid. Credits and credit packs are not refundable once bought, except where the law requires. A signed-in person with the admin scope buys; an API key cannot. The subscription is not bought here. 503 when card payments are not switched on.

| Property | Value |
| --- | --- |
| Endpoint | `POST https://mydatahub.emailmovers.com/v1/account/purchases` |
| Operation id | `createCreditPurchase` |
| Cost | Free (0 credits) |
| Access | A signed-in person only: an OAuth 2.1 token with the scope `admin`. API keys are refused. |
| Idempotency-Key | Optional. Makes a retry safe. |
| Tag | Account |

> **Not yet live.** MyDataHub v2 is built and tested locally and is not yet live. The v2 hosts open at launch, date to confirm. This page describes the contract the API is tested against.

## Request

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `MyDataHub-Version` | header | string | no | The dated API version, pinned per key when left out. Non-breaking changes ship as new dates; the path major changes only for breaking changes, with a 12 month deprecation window. |
| `Idempotency-Key` | header | string | no | Makes the request safe to retry: the same key returns the stored response. Keys are kept for the life of the organisation. |

### Body

| Member | Type | Required | Description |
| --- | --- | --- | --- |
| `lines` | array of `CreditPurchaseLineRequest` | yes | What to buy: credits at the scale, credit packs, or both, as a quote's purchase option lists them. Each id at most once. |
| `lines[].id` | string | yes | per_contact_scale for credits at the scale, or the id of a credit pack on sale, such as credit_pack_40000 (GET /v1/plans). |
| `lines[].quantity` | integer (1 to 1000000) | yes | Credits at the scale (1 to 1,000,000), or credit packs (1 to 25). |

## Example request

```bash
curl -X POST "https://mydatahub.emailmovers.com/v1/account/purchases" \
  -H "Authorization: Bearer $MYDATAHUB_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
  "lines": [
    {
      "id": "credit_pack_40000",
      "quantity": 1
    }
  ]
}'
```

## Responses

### 201: The pending purchase and its checkout_url.

Headers: `RateLimit`, `RateLimit-Policy`, `Location`.

| Member | Type | Always present | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Format: a credit purchase id: pur_ followed by a 26 character ULID in upper case Crockford base32, such as pur_01JB4R7K2V7XN3M9R6T1Y5W8ZA. |
| `status` | enum: `pending`, `paid`, `expired` | yes | pending until Stripe says the payment is paid; paid once the credits are in the balance; expired when the checkout closed unpaid (nothing was taken). |
| `label` | string | yes | What is bought, in words, numbers first. |
| `lines` | array of `CreditPurchaseLine` | yes |  |
| `lines[].id` | string | yes | per_contact_scale or a credit pack id. |
| `lines[].quantity` | integer (0 or more) | yes | Credits at the scale, or credit packs. |
| `lines[].credits` | integer (0 or more) | yes |  |
| `lines[].gbp_ex_vat` | number (0 or more) | yes | Pounds sterling excluding VAT, to two decimal places. |
| `credits` | integer (0 or more) | yes | Credits the purchase adds when paid. |
| `gbp_ex_vat` | number (0 or more) | yes | Pounds sterling excluding VAT, to two decimal places. |
| `vat_rate_percent` | number (0 to 100) | yes |  |
| `vat_gbp` | number (0 or more) | yes | Pounds sterling, to two decimal places. |
| `total_gbp` | number (0 or more) | yes | What the card is charged, VAT included. |
| `currency` | `GBP` | yes |  |
| `gbp_per_credit` | number (0 or more) | yes | What one credit of this purchase cost, excluding VAT, to six decimal places. Orders value the credits they spend at it. |
| `credits_added` | integer (0 or more) | yes | 0 until the payment is paid. |
| `checkout_url` | string (uri) or null | yes | Stripe Checkout, while the purchase is pending; null once paid or expired. |
| `test_mode` | boolean | yes | Made with a Stripe test key: test cards only, and no money moves. |
| `created_at` | string (date-time) | yes |  |
| `expires_at` | string (date-time) or null | yes | When the checkout closes if it is not paid. |
| `paid_at` | string (date-time) or null | yes |  |
| `attention` | `PurchaseAttention` or null | yes |  |
| `attention.reason` | enum: `checkout_mismatch`, `billed_outside_uk` | yes | checkout_mismatch: Stripe says it is paid but the checkout does not match this purchase, so no credits were added; billed_outside_uk: credited, but the billing address is not in the UK. |
| `attention.detail` | string | yes | What a person should look at, in plain English. |
| `attention.since` | string (date-time) | yes |  |
| `receipt` | `PaymentReceipt` or null | yes | Once paid. The buyer's name, billing address and VAT number are shown only to a signed-in person with admin; anyone else sees them as null. |
| `receipt.number` | string | yes | The receipt number, such as MDH-000042: one sequence for every receipt, with no number skipped. |
| `receipt.issued_at` | string (date-time) | yes |  |
| `receipt.supplier` | object | yes | Who sold the credits, as the server's settings name them when the receipt is written. In production a receipt is never written without them; a development server may leave them null. |
| `receipt.customer` | object | yes | Who bought the credits, as Stripe Checkout collected it. |
| `receipt.description` | string | yes | What was bought, such as 1 credit pack of 40,000. |
| `receipt.credits` | integer (0 or more) | yes |  |
| `receipt.gbp_per_credit` | number (0 or more) | yes | The unit price: one credit, excluding VAT, to six decimal places. |
| `receipt.gbp_ex_vat` | number (0 or more) | yes | Pounds sterling excluding VAT, to two decimal places. |
| `receipt.vat_rate_percent` | number (0 to 100) | yes |  |
| `receipt.vat_gbp` | number (0 or more) | yes | Pounds sterling, to two decimal places. |
| `receipt.total_gbp` | number (0 or more) | yes | What the card was charged, VAT included. |
| `receipt.paid_by` | `card` | yes | Card, through Stripe Checkout. |
| `receipt.payment_reference` | string | yes | Stripe's reference for the payment. |
| `receipt.refunds` | string | yes | The refund terms the purchase was made under. |
| `receipt.test_mode` | boolean | yes | A Stripe test mode payment: no money moved. |

#### Example response

```json
{
  "id": "pur_01JB4R7K2V7XN3M9R6T1Y5W8ZA",
  "label": "1 credit pack of 40,000",
  "lines": [
    {
      "id": "credit_pack_40000",
      "quantity": 1,
      "credits": 40000,
      "gbp_ex_vat": 1249
    }
  ],
  "credits": 40000,
  "gbp_ex_vat": 1249,
  "vat_rate_percent": 20,
  "vat_gbp": 249.8,
  "total_gbp": 1498.8,
  "currency": "GBP",
  "gbp_per_credit": 0.031225,
  "test_mode": true,
  "created_at": "2026-10-16T10:00:00Z",
  "expires_at": "2026-10-17T10:00:00Z",
  "attention": null,
  "status": "pending",
  "credits_added": 0,
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_EXAMPLE",
  "paid_at": null,
  "receipt": null
}
```

## Errors

Every error is a problem object with a `code` and a `next_step`. Every response carries the `RateLimit` and `RateLimit-Policy` headers; see [Rate limits](https://emailmovers.com/mydatahub/docs/rate-limits.md).

| Status | Problem codes | When |
| --- | --- | --- |
| 400 | [`invalid-request`](https://emailmovers.com/mydatahub/docs/errors/invalid-request.md), [`idempotency-key-required`](https://emailmovers.com/mydatahub/docs/errors/idempotency-key-required.md), [`unsupported-version`](https://emailmovers.com/mydatahub/docs/errors/unsupported-version.md) | The request could not be read, or an Idempotency-Key is missing. |
| 401 | [`authentication-required`](https://emailmovers.com/mydatahub/docs/errors/authentication-required.md), [`account-required`](https://emailmovers.com/mydatahub/docs/errors/account-required.md) | Sign in (OAuth) or send an API key (authentication-required), or create a free account for what the anonymous tier does not cover (account-required). |
| 403 | [`insufficient-scope`](https://emailmovers.com/mydatahub/docs/errors/insufficient-scope.md), [`confirmation-required`](https://emailmovers.com/mydatahub/docs/errors/confirmation-required.md), [`business-details-required`](https://emailmovers.com/mydatahub/docs/errors/business-details-required.md), [`licence-acceptance-required`](https://emailmovers.com/mydatahub/docs/errors/licence-acceptance-required.md), [`dpa-acceptance-required`](https://emailmovers.com/mydatahub/docs/errors/dpa-acceptance-required.md), [`agent-budget-exceeded`](https://emailmovers.com/mydatahub/docs/errors/agent-budget-exceeded.md) | The token lacks a scope (insufficient-scope, with required_scope and a WWW-Authenticate step-up challenge); no person confirmed the spend and no agent key covers it (confirmation-required, with an approval_url); an agent key's per-order cap or monthly budget does not cover it (agent-budget-exceeded); an account made by self-serve sign-up has not yet confirmed its business name and that it is a UK business, which its first order needs (business-details-required); the order needs the Data Licence and Sharing Agreement accepted at the version in force (licence-acceptance-required); or a suppression upload comes before the organisation has accepted the Article 28 data processing agreement in force (dpa-acceptance-required). Nothing is charged. |
| 409 | [`idempotency-key-reused`](https://emailmovers.com/mydatahub/docs/errors/idempotency-key-reused.md), [`quote-expired`](https://emailmovers.com/mydatahub/docs/errors/quote-expired.md), [`order-not-ready`](https://emailmovers.com/mydatahub/docs/errors/order-not-ready.md), [`order-not-cancellable`](https://emailmovers.com/mydatahub/docs/errors/order-not-cancellable.md) | An Idempotency-Key reused with different parameters, an expired machine quote, or an object in the wrong state. |
| 422 | [`validation-failed`](https://emailmovers.com/mydatahub/docs/errors/validation-failed.md), [`quote-required`](https://emailmovers.com/mydatahub/docs/errors/quote-required.md), [`dataset-unavailable`](https://emailmovers.com/mydatahub/docs/errors/dataset-unavailable.md) | The request is not valid. errors lists every problem with a JSON pointer and did_you_mean. |
| 429 | [`rate-limited`](https://emailmovers.com/mydatahub/docs/errors/rate-limited.md) | Too many requests. Wait for Retry-After. |
| Any other failure | [`internal-error`](https://emailmovers.com/mydatahub/docs/errors/internal-error.md), [`service-unavailable`](https://emailmovers.com/mydatahub/docs/errors/service-unavailable.md), [`tps-screen-expired`](https://emailmovers.com/mydatahub/docs/errors/tps-screen-expired.md) | Something failed on our side (500 internal-error) or the service is briefly unavailable (503 service-unavailable, with Retry-After). Nothing was charged; retry with the same Idempotency-Key. A count, sample, quote or order with telephone in play is also refused with 503 tps-screen-expired, with no Retry-After, while the dataset version's TPS and CTPS screen is more than 28 days old. |
