# POST /partner/mint/batch

Send a batch of product rows, in JSON or in CSV, up to 500 rows per call. Scope mint:batch. The products created by this path are identified by QR.

Source: https://docs.sealtrust.io/en/reference/post-partner-mint-batch/

---

You send a batch of product rows, and you receive a batch
identifier. We answer as soon as we accept the batch, before any processing. You
then follow the progress with `GET /v1/partner/mint/batch/status/{job_id}`.
Read the warning below before reading that status.

> [!DANGER] This endpoint creates products identified by QR, and nothing else
> The server itself sets, on every row, the `qr` identification method
> and the technical identifier of the product. You can supply neither of
> them: a row that carries them makes the request fail with a 400. A machine
> call has no chip in hand, so this path produces no NFC
> item. For NFC items, go through the console.

> [!ATTENTION] The status says where the job stands, its counters say what exists
> `GET /v1/partner/mint/batch/status/{job_id}` describes first of all how the
> processing unfolds. What has been created is read in its counters, `success_count` and
> `error_count`, present from the very first call. A batch whose every row was
> rejected still goes to the end of its processing, and then reads
> `status: "failed"`, `is_finished: true` and `success_count: 0`. A batch whose
> processing stopped along the way also reads
> `status: "failed"`, this time with a non-zero `success_count`: those items
> exist, do not throw the batch away without having read it.

Full address:

```http
POST https://api.sealtrust.io/v1/partner/mint/batch
```

The same endpoint also answers without the `/v1` prefix, at
`https://api.sealtrust.io/partner/mint/batch`. The two addresses call the
same code. Use the `/v1` form for a new integration.

## Authorization

Send your API key in the `Authorization` header, in the `Bearer` format.
The key must carry the `mint:batch` scope. A key that does not carry this scope
receives a 403 whose message names the missing scope.

Three conditions come on top of the scope of the key.

- The plan of your brand must include API access. Otherwise you receive a
  403 carrying the code `FEATURE_NOT_AVAILABLE`.
- The plan of your brand must allow identification by QR. That is the
  method the server sets on every row of this endpoint. A plan
  that does not allow it receives a 403 carrying the code
  `AUTH_METHOD_NOT_ALLOWED`.
- All the rows of the batch must carry the brand number of the key. A
  single row carrying another number makes the whole batch refused with a 403.

## Rate limit

We measure the rate over a fixed 60-second window. We first apply
the value set on your account, if we have set one. Otherwise the one
of your plan. In the absence of both, the fallback value is 120
calls per window.

Two counters are layered, with the same cap: one per key, one for the
sum of all the keys of your brand. Creating additional keys therefore does not
increase the total rate allowed.

As soon as we recognize your key, the response carries four headers. They
describe the more constraining of the two counters. An authentication
refusal comes before the counting and carries none of these headers.

| Header | Content |
| --- | --- |
| `X-RateLimit-Limit` | the cap applied over the window |
| `X-RateLimit-Remaining` | what is left to you in the current window |
| `X-RateLimit-Reset` | the end-of-window timestamp, in seconds |
| `X-RateLimit-Scope` | `key` or `brand`, the counter used as the reference |

A rate refusal returns 429. It carries these four headers, describing this
time the counter that refused, and it adds `Retry-After`, expressed in seconds
remaining in the current window. This value is never lower than 1.

Two other caps can refuse the same call: the daily quota of
your key, which is consumed per item, and the monthly product quota of
your plan. You will find them in the error table, further down.

## Path and query parameters

This endpoint has neither path parameter nor query parameter.

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Authorization` | `string` | yes | `Bearer` followed by your API key. |
| `Content-Type` | `string` | yes | `application/json` for a batch in JSON, `multipart/form-data` for a CSV file. |
| `Idempotency-Key` | `string` | no | Your own call identifier. We remember it for 24 hours. See the section on replaying. |

## Request body

You send the batch in one of the two formats. The content is the same in
both cases.

- **JSON**: send a list of objects. A lone object, a number or a string
  at the top level makes the request fail with a 400.
- **CSV**: send a file as `multipart/form-data`, under the form
  field named exactly `file`. Encode the file in UTF-8. You may
  leave the byte order mark at the head of the file, we remove it. A
  file encoded otherwise makes the call fail with a 500. In a spreadsheet,
  choose the "CSV UTF-8" export. We strip the edge spaces of the
  cells before validating.

You can compress the CSV file in gzip or in zlib, we
decompress it. If the decompressed content exceeds about 20 MiB, you receive
a 413.

A batch has at most 500 items. We refuse an empty batch with a 400.

### Fields of a row

A row accepts five fields, and only five.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `product_name` | `string` | yes | The name of the product. |
| `brand_id` | `integer` | yes | The number of your brand. It must be identical on every row and match the brand of the key. |
| `category_id` | `integer` | yes | The number of the category of the product. The category must exist. |
| `metadata_uri` | `string` | yes | The address of the metadata of the product. |
| `external_ref` | `string` | no | Your own reference. We keep it on the row of the batch. No partner endpoint returns it to you: the batch status does not contain it, and the product created does not carry it, no product field takes it up. You find it again only in the 400 error report of a CSV submission, which names the refused row. |

In CSV, the header must contain at least the four required columns.
The order of the columns is free.

The server ignores no field. Any field this table does not name makes
the row fail, and therefore the batch, with a 400.

> [!DANGER] `owner_email` and `contract_address` have been refused since 08/20/2026
> These two fields used to be accepted then thrown away without being read. A partner who
> minted for an end customer believed they were filling in the owner of the
> item, and were filling in nothing. Today they make the request fail with a
> 400. Remove them from your code before your next call.

### Replaying a call without doubling its effect

You can send an `Idempotency-Key` header. It is optional. We remember it
for 24 hours.

- Replay the same idempotency key with the same batch, and you receive the
  response of the first call, without a second processing starting.
- Replay the same idempotency key with a different batch, and you receive a
  409. This behavior dates from 08/20/2026. Before that, the second batch was
  never processed and nothing reported it.
- Replay an idempotency key whose call is still being
  processed, and you also receive a 409. This lock expires on its own after
  60 seconds.

We compare two batches on their content once read and normalized. The order of the
rows, the order of the columns, the choice between CSV and JSON and the empty cells
make no difference.

> [!INFO] What a replay consumes
> A replay consumes one token of your rate cap. It consumes neither the
> monthly quota of your plan, nor the daily quota of your key.

## Example request

A batch of two items, in JSON.

:::onglets
```bash title="curl"
curl -i -X POST https://api.sealtrust.io/v1/partner/mint/batch \
  -H "Authorization: Bearer st_test_0000000000000000000000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: lot-exemple-0001" \
  -d '[
    {
      "product_name": "Sac Exemple 001",
      "brand_id": 12,
      "category_id": 3,
      "metadata_uri": "ipfs://exemple-metadonnees-0001",
      "external_ref": "EX-0001"
    },
    {
      "product_name": "Sac Exemple 002",
      "brand_id": 12,
      "category_id": 3,
      "metadata_uri": "ipfs://exemple-metadonnees-0002",
      "external_ref": "EX-0002"
    }
  ]'
```
```typescript
import { SealTrustClient } from "@sealtrust-io/sdk";

const sealtrust = new SealTrustClient({
  apiKey: "st_test_0000000000000000000000000000000000000000000000",
});

const lot = await sealtrust.products.mint(
  [
    {
      product_name: "Sac Exemple 001",
      brand_id: 12,
      category_id: 3,
      metadata_uri: "ipfs://exemple-metadonnees-0001",
      external_ref: "EX-0001",
    },
    {
      product_name: "Sac Exemple 002",
      brand_id: 12,
      category_id: 3,
      metadata_uri: "ipfs://exemple-metadonnees-0002",
      external_ref: "EX-0002",
    },
  ],
  "lot-exemple-0001",
);

console.log(lot.job_id, lot.status, lot.items_count, lot.brand_id);
```
```python
import requests

lot = [
    {
        "product_name": "Sac Exemple 001",
        "brand_id": 12,
        "category_id": 3,
        "metadata_uri": "ipfs://exemple-metadonnees-0001",
        "external_ref": "EX-0001",
    },
    {
        "product_name": "Sac Exemple 002",
        "brand_id": 12,
        "category_id": 3,
        "metadata_uri": "ipfs://exemple-metadonnees-0002",
        "external_ref": "EX-0002",
    },
]

response = requests.post(
    "https://api.sealtrust.io/v1/partner/mint/batch",
    headers={
        "Authorization": "Bearer st_test_0000000000000000000000000000000000000000000000",
        "Idempotency-Key": "lot-exemple-0001",
    },
    json=lot,
    timeout=30,
)

print(response.status_code)
print(response.headers["X-RateLimit-Remaining"])
print(response.json())
```
:::

> [!ATTENTION] The SDK makes up an idempotency key if you do not give it one
> `products.mint()` always sends an `Idempotency-Key` header. Without a second
> argument, it draws a new one at every call, and two successive calls
> therefore send two batches. Pass your own value, as in the example, if
> you want a retry to stay without effect.

The same batch in CSV, sent as `multipart/form-data`. The `lot.csv` file
contains:

```text title="lot.csv"
product_name,brand_id,category_id,metadata_uri,external_ref
Sac Exemple 001,12,3,ipfs://exemple-metadonnees-0001,EX-0001
Sac Exemple 002,12,3,ipfs://exemple-metadonnees-0002,EX-0002
```

The TypeScript SDK does not expose CSV submission. The TypeScript tab below
therefore calls the endpoint directly.

:::onglets
```bash title="curl"
curl -i -X POST https://api.sealtrust.io/v1/partner/mint/batch \
  -H "Authorization: Bearer st_test_0000000000000000000000000000000000000000000000" \
  -H "Idempotency-Key: lot-exemple-0001" \
  -F "file=@lot.csv;type=text/csv"
```
```typescript
import { readFile } from "node:fs/promises";

const contenu = await readFile("lot.csv");

const formulaire = new FormData();
formulaire.append("file", new Blob([contenu], { type: "text/csv" }), "lot.csv");

const reponse = await fetch("https://api.sealtrust.io/v1/partner/mint/batch", {
  method: "POST",
  headers: {
    Authorization:
      "Bearer st_test_0000000000000000000000000000000000000000000000",
    "Idempotency-Key": "lot-exemple-0001",
  },
  body: formulaire,
});

console.log(reponse.status);
console.log(reponse.headers.get("X-RateLimit-Remaining"));
console.log(await reponse.json());
```
```python
import requests

with open("lot.csv", "rb") as fichier:
    response = requests.post(
        "https://api.sealtrust.io/v1/partner/mint/batch",
        headers={
            "Authorization": "Bearer st_test_0000000000000000000000000000000000000000000000",
            "Idempotency-Key": "lot-exemple-0001",
        },
        files={"file": ("lot.csv", fichier, "text/csv")},
        timeout=30,
    )

print(response.status_code)
print(response.headers["X-RateLimit-Remaining"])
print(response.json())
```
:::

## Example response

HTTP code `200`.

```json
{
  "job_id": "0000a1b2c3d4e5f6",
  "status": "queued",
  "items_count": 2,
  "brand_id": 12
}
```

The response has four fields and nothing else.

| Field | Type | Description |
| --- | --- | --- |
| `job_id` | `string` | The identifier of the batch. Pass it to `GET /v1/partner/mint/batch/status/{job_id}`. |
| `status` | `string` | Always `queued` on this response. |
| `items_count` | `integer` | The number of rows accepted in the batch. |
| `brand_id` | `integer` | The number of your brand. |

`status: "queued"` means that we have accepted the batch and that it is waiting for its
processing. We have processed nothing at that moment. Follow what comes next with
`GET /v1/partner/mint/batch/status/{job_id}`.

## Errors

The body of an error response carries a `detail` field. Depending on the case, this field
contains a sentence or an object.

A refusal that carries a sentence, here the empty batch, HTTP code `400`:

```json
{
  "detail": "Batch vide (aucun item)"
}
```

A refusal that carries an object, here an incomplete CSV header, HTTP code `400`:

```json
{
  "detail": {
    "error": "Colonnes manquantes",
    "missing": ["category_id", "metadata_uri"]
  }
}
```

| Code | Condition | What to do |
| --- | --- | --- |
| 400 | The JSON body sent is malformed. | Check that you are sending valid JSON, or a CSV file as `multipart/form-data`. |
| 400 | The JSON body is not a list at the top level. | Wrap your object in a list, even for a single item. |
| 400 | One or more JSON rows are invalid. `detail` is `{"error": "JSON invalide", "rows": [...]}`, each entry carrying the `index` of the row at fault and the reason. | Correct the rows reported. The first object of the list carries index 1. No row of the batch was processed. |
| 400 | The CSV file is empty or has no header line. `detail` is `{"error": "CSV vide ou sans en-têtes"}`. | Add the header line. |
| 400 | A required column is missing from the CSV header. `detail` is `{"error": "Colonnes manquantes", "missing": [...]}`. | Add the columns named in `missing`. The file was not read beyond its header. |
| 400 | One or more CSV rows are invalid. `detail` is `{"error": "CSV invalide", "rows": [...]}`, each entry carrying the `line` of the file, its `external_ref` and the reason. | Correct the rows reported. The first data line carries number 2. |
| 400 | The gzip file sent is unreadable. | Send the file again, or send it without compression. |
| 400 | The batch is empty. | Send at least one row. |
| 400 | The batch exceeds 500 rows. The message gives the number received and the cap. | Split your submission into several calls of 500 rows at most. |
| 400 | One or more `category_id` values match no category. The message lists the numbers that cannot be found. | Correct the category numbers. The list of categories is visible in the console. |
| 401 | The `Authorization` header is missing. The response also carries `WWW-Authenticate: Bearer`. | Add the header. |
| 401 | The `Authorization` header does not start with `Bearer ` followed by a space. The response also carries `WWW-Authenticate: Bearer`. | Correct the form of the header. |
| 401 | The value sent after `Bearer ` is empty or is shorter than 40 characters. | Send the complete secret, with no space and no line break. |
| 401 | The key sent is unknown. | Check that you are using a key of this environment, and that it has not been recreated. |
| 403 | The key is no longer active, because it was revoked. The message gives its state. | Create a new key in the console. |
| 403 | The key has reached its expiration date. | Create a new key. The old one will never become valid again. |
| 403 | The key does not carry the `mint:batch` scope. The message names the missing scope. | Create a key carrying this scope. The scopes of an existing key cannot be modified. |
| 403 | The plan of your brand does not include API access. `detail` is `{"code": "FEATURE_NOT_AVAILABLE", "feature": "api_access"}`. | Contact us to open this right on your plan. Trying again will change nothing. |
| 403 | The plan of your brand does not allow identification by QR, which is the method set by this endpoint. `detail` carries the code `AUTH_METHOD_NOT_ALLOWED`. | Contact us to open QR on your plan. Trying again will change nothing. |
| 403 | At least one row carries a `brand_id` different from the one of the key. The message gives the expected number and the number of rows at fault. | Correct the rows. No row of the batch was processed. |
| 403 | The batch would exceed the monthly product quota of your plan. `detail` is `{"code": "QUOTA_EXCEEDED", "resource": "products", "current": ..., "additional": ..., "max": ..., "period": "monthly"}`. | Wait for the next period, reduce the size of the batch, or contact us to change plan. This refusal does not consume the daily quota of your key. |
| 409 | The idempotency key has already been used for a different batch in the last 24 hours. | Use a new idempotency key for this batch. This batch was not processed. |
| 409 | A call carrying the same idempotency key is being processed. | Wait for the response of the first call, then read its result again. The lock expires after 60 seconds. |
| 413 | The compressed file exceeds about 20 MiB once decompressed. | Split your file. A batch of 500 rows stays well below this cap. |
| 429 | The rate cap of the key is reached. `Retry-After` and the `X-RateLimit-*` family come with the response, with `X-RateLimit-Scope: key`. | Wait the number of seconds given by `Retry-After`, then try again. |
| 429 | The rate cap of the brand is reached, across all keys. `X-RateLimit-Scope` is `brand`. | Wait the number of seconds given by `Retry-After`. Creating an additional key does not raise this cap. |
| 429 | The daily quota of the key is reached. The `X-Quota-Limit`, `X-Quota-Remaining` and `X-Quota-Reset` headers come with the response. | Wait for midnight universal time to pass, or have the quota of the key raised. The quota is consumed per item: a batch of 100 rows consumes 100 of it. |
| 500 | The CSV file sent is not encoded in UTF-8. | Save the file again in UTF-8. In a spreadsheet, choose the "CSV UTF-8" export. |
| 500 | Your JSON list contains an element that is not an object, for example a string or a number. | Send a list in which every element is an object carrying the fields described above. |
| 500 | A `category_id` outside the bounds the database accepts. | Send category numbers visible in the console. |
| 500 | The brand attached to your key cannot be found. | Contact us giving the time of the call. |
| 500 | An unexpected error occurred during the processing of your call. | Try again. If the error persists, contact support giving the time of the call. |
| 503 | The call-capping service is momentarily unavailable. We then refuse the call. | Try again in a few moments. No row was processed. |

> [!ATTENTION] The order of the checks has consequences for your quotas
> We check the monthly quota of your plan before consuming the daily
> quota of your key. A batch refused by your plan therefore does not burn
> your quota for the day. Conversely, an accepted batch consumes the daily quota
> up to the number of rows, at the moment of acceptance, before any
> processing. We consume this quota before the queuing. A call that
> fails after this step returns a 500 and leaves the quota consumed. Count it
> in your retries.

## See also

- [`GET /partner/mint/batch/status/{job_id}`](/en/reference/get-partner-mint-batch-status/),
  follow the progress of a batch sent to minting.
- [Create products, one at a time and in batches](/en/creer-des-produits/),
  create a product, create a whole run, import a file.
- [Partner API, overview](/en/api-vue-ensemble/),
  base address, keys, scopes, call caps and pagination.
- [API errors](/en/api-erreurs/),
  recognize an error code and decide whether to correct or replay.
