> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gigstack.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Platform Payouts API Guide

> Integration guide for Platform Payouts

## Overview

Platform payouts is for marketplaces and digital platforms that pay **providers** under the SAT digital-platforms scheme (Plataformas Tecnológicas: RESICO régimen `625`, retention key `26`). Ride-hailing drivers, delivery couriers, lodging hosts and sellers of goods are all providers. The platform issues the CFDIs for a whole payout period from two files:

* a **movements** file, with one row per payout to a provider;
* a **commissions** file, with one row per provider per month holding the platform commission.

gigstack matches every row to the provider's gigstack team in your billing account and computes a plan. Once you confirm the plan, it stamps every document in the background.

> **What it is not.** This API is for a platform that invoices **on behalf of its providers**. If your company invoices its own sales, one CFDI per sale, use [`POST /invoices/income`](/guides/invoices) instead.

Base path: `https://api.gigstack.io/v2/platform-payouts`

A run goes through three steps:

1. **Plan.** `POST /platform-payouts` uploads both files. The plan is computed during the request and returned in the response. Nothing is stamped yet.
2. **Review.** `GET /platform-payouts/{id}` returns the totals. `GET /platform-payouts/{id}/movements` lists every row with what will be issued for it, or why nothing will be.
3. **Confirm.** `POST /platform-payouts/{id}/confirm` hands the plan to the stamping worker. **This cannot be undone.** Poll `GET /platform-payouts/{id}` until the run is `completed` or `failed`, then read its [`result`](#run-result) to see how it went.

## Key Features

* **Three documents per payout period** - the provider's income invoice, your retention certificate and your monthly commission invoice
* **Tax policy per account** - the regimes, SAT keys and withholding rates come from your account's configuration (see [Tax Policy](#tax-policy))
* **Synchronous planning** - the plan, or the reason it failed, is in the response to the upload
* **Idempotent uploads** - a required `Idempotency-Key` makes a retried upload return the same run. If the key comes back with different files, it is refused
* **Row-level exclusions** - a malformed or ineligible row is excluded with a reason and a stable code, and the other rows still go ahead
* **A clear outcome** - a finished run reports `result`: `completed`, `partially_completed` or `failed`
* **No double certification** - a provider-month already certified by an earlier run is skipped
* **Safe confirm** - confirming twice returns the run's status and does nothing else

## Who Can Use It

Platform payouts is for **marketplace master teams**. All of these must hold:

| Requirement | Error when it doesn't (`403`) |
| - | - |
| The team is a marketplace master team | `not_master_team` |
| The team has a billing account | `no_billing_account` |
| Platform payouts is enabled on that billing account (ask support to enable it) | `feature_disabled` |

On `POST /platform-payouts`, these checks (and `team_not_found` / `not_a_member`) run right after the `Idempotency-Key` check and **before the upload is read**. The files of a refused request are never processed or stored.

Credentials:

* **API keys and OAuth access tokens** belong to the team. They can create, read and confirm that team's runs.
* **User-scoped tokens** (MCP tokens, dashboard sessions) act as a person, who must be a member of the team (`403 forbidden` / `not_a_member` otherwise). The two `POST` routes also require **`editor` permission on invoices** (`403 forbidden`, `This action requires editor access to invoices`).
* **gigstack Connect doesn't apply.** Runs always act on the credential's own master team. Sending `?team=<connected team>` moves the request to a team that isn't a master team. Uploads and confirms are then refused with `not_master_team`, and reads return `404`.

**The mode comes from the credential only.** A test key creates and sees test runs, and a live key creates and sees live runs. A run of another team, or of the other mode, returns `404 run_not_found`, never `403`, so an id can't be used to discover someone else's run.

## What Gets Issued

For each movement (payout) and each provider-month, the plan decides which of three comprobantes to issue:

| Kind (`planned_documents`) | Issued by | Issued to | One per | Notes |
| - | - | - | - | - |
| `income` | The provider's gigstack team | Your master team | Movement | CFDI de ingreso for the payout, with the ISR and IVA withholdings |
| `certificate` | Your master team | The provider | Movement | Constancia de Retenciones, key `26` (Plataformas Tecnológicas). Reports this movement's share of the monthly commission |
| `commission` | Your master team | The provider | Provider-month in the commissions file | Invoice for the month's platform commission |

The monthly commission is **prorated** across the provider's movements of that month, in proportion to each subtotal. That share is the movement's `commission`, and it is what the certificate reports. The commission invoice bills the full monthly figure.

Which documents a provider qualifies for depends on your account's [tax policy](#tax-policy). Rows that don't qualify are excluded with a Spanish reason and a stable code. See [Exclusion codes](#exclusion-codes).

## Tax Policy

The documents a run issues, and the SAT keys and rates they carry, are set **per master account** when the account is onboarded. The same files can produce different CFDIs for a delivery platform and for a ride-hailing platform. The policy covers:

* **Eligibility**: which tax regimes providers may be on, whether a provider needs a valid CSD (sellos) in gigstack, and whether a certificate may be issued to *público en general* (the generic RFC).
* **Retention certificate**: the service type (`tipoDeServ`) and subtype (`subTipServ`) of the digital-platforms complement, and the ISR and IVA withholding rates.
* **Invoices**: the product keys of the income and commission invoices, the unit, the concept descriptions, and the CFDI use, payment form, payment method and currency.

If your account has no specific configuration, the defaults apply. The defaults are set for **ground passenger transport**, so check them before your first live run:

| Setting | Default |
| - | - |
| Allowed tax regimes | `625` (RESICO) |
| CSD required | Yes |
| Certificates to *público en general* | Not allowed |
| Retention key (`CveRetenc`) | `26` |
| Service type / subtype (`tipoDeServ` / `subTipServ`) | `01` (ground passenger transport) / `04` |
| Periodicity | `02` (monthly) |
| ISR withholding | 2.1% |
| IVA withholding | 8% (on 16% IVA) |
| Income invoice product key | `78101800` |
| Commission invoice product key | `80141600` |
| Unit | `E48` (Unidad de servicio) |
| Income concept | `Servicio de transporte terrestre de pasajeros - {movementType} - {date}`, filled from `Tipo de movimiento` and `Fecha del movimiento` |
| Commission concept | `Comision por uso de plataforma tecnologica - {month}`, filled from `Mes` |
| CFDI use, payment form, method, currency | `G03`, `03`, `PUE`, `MXN` |

If your providers deliver goods, host lodging or provide another kind of service, **contact support to configure your policy** before your first live run. The policy can't be changed through the API.

## Input Files

Both files are sent in one `multipart/form-data` request:

| Form field | Content |
| - | - |
| `movements_file` | One row per payout |
| `commissions_file` | One row per provider per month |

Rules for both:

* **The format is chosen by extension** (the MIME type is ignored). `.csv` and `.txt` are read as comma-separated text (UTF-8, a BOM is fine). `.xlsx`, `.xls` and `.xlsm` are read as workbooks, and only the **first sheet** is used.
* **Max 5 MB** per file (`413 file_too_large`) and **max 50,000 rows** (the plan fails).
* The first row is the header. Headers match regardless of case, accents and repeated spaces (`CORREO ELECTRONICO` matches `Correo electrónico`). Extra columns are ignored.
* **Amounts** are in MXN. `1250.00`, `1,250.00`, `1250,5` and `$1,250.00` are all read. Any other value is a row problem, never a silent zero.
* **Column names**: each column below has a primary name and a few accepted alternatives. Use the primary name in new files. When a column is missing, the error names it by its primary name.

### Provider columns (both files)

| Column | Also accepted | Required | Format |
| - | - | - | - |
| `ID del proveedor` | `Provider ID`, `Driver ID` | Yes | Your id for the provider. Matched against the `metadata.driverId` of the provider's team |
| `Nombre del proveedor` | `Nombre del conductor`, `Razón social`, `Nombre` | Yes | The provider's legal name. In the movements file, it must match the legal name of the provider's gigstack team, or the income invoice is excluded |
| `Correo electrónico` | `Correo`, `Email` | Yes | Used to match the provider when the id and `RFC` don't |
| `RFC` | | Yes | The provider's RFC |

When a file has more than one name column, the more specific one wins: `Nombre del proveedor` or `Nombre del conductor`, then `Razón social`, then a bare `Nombre`.

### Movements file columns

The [provider columns](#provider-columns-both-files), plus:

| Column | Required | Format |
| - | - | - |
| `Fecha del movimiento` | Yes | `YYYY-MM-DD` or `DD/MM/YYYY` (`-` is also accepted as the separator) |
| `Tipo de movimiento` | Yes | Free text used in the invoice concept, for example `Pago semanal` or `Servicio` |
| `Subtotal` | Yes | At least `0.01` |

### Commissions file columns

The [provider columns](#provider-columns-both-files), plus:

| Column | Also accepted | Required | Format |
| - | - | - | - |
| `Mes` | | Yes | `YYYY-MM` |
| `Comisión` | `Comisión Total`, and a few legacy export spellings | Yes | The month's platform commission, MXN |

Commissions are joined to movements on the provider id and the month. The commission invoice is excluded if the row has no `RFC`. If a provider-month appears more than once, the first row is used.

**A certificate needs a commission.** The SAT rejects a retention certificate whose commission is zero (`SPT147`). A movement whose provider-month has no commission in the commissions file therefore gets no certificate.

### File problems and row problems

* A **file** problem fails the whole plan. The run comes back as `plan_failed` with a Spanish `error`. Examples: the file can't be read, it's empty, it has more than 50,000 rows, or required columns are missing (all missing columns are listed at once, for example `Al archivo de comisiones le faltan estas columnas: Comisión.`).
* A **row** problem (an invalid date or amount, a subtotal below `0.01`) only excludes that movement, with the problem as its reason.

### Matching providers

Each row is matched to a team **in your billing account**. gigstack tries the provider id first (the team's `metadata.driverId`), then the `RFC`, then the e-mail. When an RFC or e-mail matches more than one team, the row is excluded rather than guessed.

## Run Status

| `status` | Meaning | What to do |
| - | - | - |
| `planning` | The plan is being computed | Poll `GET /platform-payouts/{id}`. You normally only see this on a retry of an upload that is still in progress |
| `plan_ready` | Plan computed, waiting for confirmation | Review it, then confirm |
| `plan_failed` | The plan couldn't be computed; see `error` | Fix the file and upload again with a **new** `Idempotency-Key` |
| `stamping` | Confirmed; the worker is issuing the CFDIs | Poll `GET /platform-payouts/{id}` |
| `completed` | The worker has nothing left to try | Read `result`: this status says nothing about how much was issued |
| `failed` | The worker stopped the whole run; see `error` | Contact support. Causes: the master team no longer exists or has no CSD, or the run made no progress after repeated attempts |

## Run Result

`status: "completed"` only means the worker has nothing left to try. To know how a finished run went, **read `result`, not `status`**. It is `null` until the run finishes (`completed` or `failed`).

| `result` | When |
| - | - |
| `completed` | No document failed: everything planned was issued |
| `partially_completed` | Some documents failed, and at least one document was issued |
| `failed` | The run itself failed (`status: "failed"`), or it finished having issued nothing while something failed |

How it's counted:

* **Failures** are `progress.failed_count` plus `progress.commission_failed_count`. A movement counts as failed as soon as **one** of its documents (income invoice or certificate) failed. Commission invoices are counted separately, because `failed_count` counts movements only.
* **Issued** documents are `income_invoices_count` + `certificates_count` + `commission_invoices_count`.

Runs that finished before `result` existed get it derived from their counters by the same rule.

## Endpoints

### Create a Run

```http theme={null}
POST /platform-payouts
```

Uploads both files and returns the planned run.

**Headers:**

| Header | Required | Description |
| - | - | - |
| `Authorization` | Yes | `Bearer YOUR_TOKEN` |
| `Idempotency-Key` | Yes | 8-128 characters from `A-Z a-z 0-9 . _ : -`. Checked before the files are read |

**Form fields:** `movements_file` and `commissions_file`, both required. See [Input Files](#input-files).

```bash theme={null}
curl -X POST https://api.gigstack.io/v2/platform-payouts \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Idempotency-Key: payouts-2026-08-v1" \
  -F "movements_file=@movimientos-agosto-2026.csv" \
  -F "commissions_file=@comisiones-agosto-2026.xlsx"
```

**Response (201):**

```json theme={null}
{
    "success": true,
    "data": {
        "id": "batchrun_3f9a1c7e2b4d6f8a0c1e3a5b7d9f1a2c",
        "status": "plan_ready",
        "result": null,
        "livemode": true,
        "team": "team_1234567890",
        "created_at": 1788220800000,
        "plan_ready_at": 1788220804120,
        "confirmed_at": null,
        "completed_at": null,
        "files": {
            "movements": "movimientos-agosto-2026.csv",
            "commissions": "comisiones-agosto-2026.xlsx"
        },
        "months": ["2026-08"],
        "total_movements": 412,
        "included_count": 398,
        "excluded_count": 14,
        "planned_documents": { "income": 398, "certificate": 398, "commission": 57 },
        "exclusion_summary": {
            "Sin sellos (CSD) en Gigstack": 9,
            "El proveedor no tiene cuenta en Gigstack": 5
        },
        "exclusion_code_summary": {
            "missing_csd": 9,
            "provider_not_found": 5
        },
        "progress": {
            "stamped_count": 0,
            "failed_count": 0,
            "income_invoices_count": 0,
            "certificates_count": 0,
            "commission_invoices_count": 0,
            "commission_failed_count": 0,
            "income_invoices_amount": 0
        },
        "error": null
    },
    "timestamp": 1788220804180
}
```

A plan that failed on the file is also a `201`, with `status: "plan_failed"`, zero counts and the reason in `error`:

```json theme={null}
{
    "success": true,
    "data": {
        "id": "batchrun_9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e",
        "status": "plan_failed",
        "error": "Al archivo de movimientos le faltan estas columnas: Fecha del movimiento, Subtotal.",
        "...": "..."
    },
    "timestamp": 1788220801070
}
```

#### Idempotency

The run id is derived from your team, the credential's mode and the `Idempotency-Key`, and the run records a fingerprint of the two files' **contents** (their bytes; the file names don't count). So:

* The **first** request with a key creates the run: `201`.
* A later request with the **same key and the same files** returns that run, in whatever status it is now: `200`. Nothing is planned again.
* The **same key with different files** is refused: `409 idempotency_key_reused`. The new files aren't planned; send them under a new key. Renaming a file doesn't change the fingerprint, and changing a single byte does.
* Runs created before the fingerprint existed have none. A later request with their key is answered as before, `200` with the existing run, whatever files it carries.
* A retry that arrives while the first request is still planning gets the run in `planning`. Poll `GET /platform-payouts/{id}`; don't upload again.
* A plan that failed **stays failed under its key**. Fix the file and upload with a **new** key.
* The same key used with a test key and with a live key names two different runs.

If a run is stuck in `planning` for more than 10 minutes (the instance planning it died), a retry with the same key takes it over and plans it again from the files in that retry.

Use one key per distinct upload, for example `payouts-2026-08-v1`, then `payouts-2026-08-v2` after a fix.

### Get a Run

```http theme={null}
GET /platform-payouts/{id}
```

Returns the run in the same shape as the create response. After confirming, `progress` fills in as the worker stamps, and `result` is set once the run finishes.

```bash theme={null}
curl https://api.gigstack.io/v2/platform-payouts/batchrun_3f9a1c7e2b4d6f8a0c1e3a5b7d9f1a2c \
  -H "Authorization: Bearer YOUR_TOKEN"
```

A finished run in which a few documents failed:

```json theme={null}
{
    "success": true,
    "data": {
        "id": "batchrun_3f9a1c7e2b4d6f8a0c1e3a5b7d9f1a2c",
        "status": "completed",
        "result": "partially_completed",
        "livemode": true,
        "team": "team_1234567890",
        "created_at": 1788220800000,
        "plan_ready_at": 1788220804120,
        "confirmed_at": 1788221400000,
        "completed_at": 1788224100000,
        "files": {
            "movements": "movimientos-agosto-2026.csv",
            "commissions": "comisiones-agosto-2026.xlsx"
        },
        "months": ["2026-08"],
        "total_movements": 412,
        "included_count": 398,
        "excluded_count": 14,
        "planned_documents": { "income": 398, "certificate": 398, "commission": 57 },
        "exclusion_summary": {
            "Sin sellos (CSD) en Gigstack": 9,
            "El proveedor no tiene cuenta en Gigstack": 5
        },
        "exclusion_code_summary": {
            "missing_csd": 9,
            "provider_not_found": 5
        },
        "progress": {
            "stamped_count": 395,
            "failed_count": 3,
            "income_invoices_count": 397,
            "certificates_count": 396,
            "commission_invoices_count": 56,
            "commission_failed_count": 1,
            "income_invoices_amount": 525528.75
        },
        "error": null
    },
    "timestamp": 1788224160000
}
```

### List Movements

```http theme={null}
GET /platform-payouts/{id}/movements
```

One entry per row of the movements file, in file order, with the state of its income invoice and retention certificate.

| Parameter | Type | Description |
| - | - | - |
| `limit` | integer | Movements per page, 1-100 (default **50**). Anything else is `400 invalid_limit` |
| `next` | string | Opaque cursor from the previous page's `data.next`. An invalid one is `400 invalid_cursor` |

Note the nested shape: the array is at `data.data` and the cursor at `data.next`. Keep requesting with `next` while `data.has_more` is `true`. The cursor stays valid while the worker updates statuses, so pages never overlap or skip rows.

```bash theme={null}
curl "https://api.gigstack.io/v2/platform-payouts/batchrun_3f9a1c7e2b4d6f8a0c1e3a5b7d9f1a2c/movements?limit=3" \
  -H "Authorization: Bearer YOUR_TOKEN"
```

```json theme={null}
{
    "success": true,
    "data": {
        "data": [
            {
                "id": "P10482_2026-08-04_125000_0",
                "line": 2,
                "status": "stamped",
                "provider": {
                    "id": "P10482",
                    "name": "ESCUELA KEMPER URGATE",
                    "email": "proveedor@example.com",
                    "tax_id": "EKU9003173C9",
                    "team": "team_0987654321"
                },
                "movement_type": "Pago semanal",
                "date": "2026-08-04",
                "month": "2026-08",
                "subtotal": 1250,
                "commission": 96.15,
                "exclusion_reason": null,
                "exclusion_codes": [],
                "error": null,
                "income": {
                    "planned": true,
                    "status": "stamped",
                    "reason": null,
                    "reason_code": null,
                    "invoice_id": "7C2E4B1A-9D3F-4E8A-B6C5-1F2A3B4C5D6E",
                    "uuid": "7C2E4B1A-9D3F-4E8A-B6C5-1F2A3B4C5D6E",
                    "total": 1323.75,
                    "error": null,
                    "error_code": null
                },
                "certificate": {
                    "planned": true,
                    "status": "stamped",
                    "reason": null,
                    "reason_code": null,
                    "invoice_id": "0A1B2C3D-4E5F-4A6B-8C7D-9E0F1A2B3C4D",
                    "uuid": "0A1B2C3D-4E5F-4A6B-8C7D-9E0F1A2B3C4D",
                    "total": null,
                    "error": null,
                    "error_code": null
                }
            },
            {
                "id": "P20931_2026-08-04_98000_0",
                "line": 3,
                "status": "excluded",
                "provider": {
                    "id": "P20931",
                    "name": "ESCUELA KEMPER URGATE",
                    "email": "otro.proveedor@example.com",
                    "tax_id": "EKU9003173C9",
                    "team": "team_1122334455"
                },
                "movement_type": "Pago semanal",
                "date": "2026-08-04",
                "month": "2026-08",
                "subtotal": 980,
                "commission": 75.38,
                "exclusion_reason": "Sin sellos (CSD) en Gigstack",
                "exclusion_codes": ["missing_csd"],
                "error": null,
                "income": {
                    "planned": false,
                    "status": "skipped",
                    "reason": "Sin sellos (CSD) en Gigstack",
                    "reason_code": "missing_csd",
                    "invoice_id": null,
                    "uuid": null,
                    "total": null,
                    "error": null,
                    "error_code": null
                },
                "certificate": {
                    "planned": false,
                    "status": "skipped",
                    "reason": "Sin sellos (CSD) en Gigstack",
                    "reason_code": "missing_csd",
                    "invoice_id": null,
                    "uuid": null,
                    "total": null,
                    "error": null,
                    "error_code": null
                }
            },
            {
                "id": "P30577_2026-08-05_110000_0",
                "line": 4,
                "status": "failed",
                "provider": {
                    "id": "P30577",
                    "name": "ESCUELA KEMPER URGATE",
                    "email": "tercer.proveedor@example.com",
                    "tax_id": "EKU9003173C9",
                    "team": "team_5566778899"
                },
                "movement_type": "Pago semanal",
                "date": "2026-08-05",
                "month": "2026-08",
                "subtotal": 1100,
                "commission": 84.62,
                "exclusion_reason": null,
                "exclusion_codes": [],
                "error": "Timbrado interrumpido: verificar en el PAC antes de reintentar",
                "income": {
                    "planned": false,
                    "status": "skipped",
                    "reason": "Sin serie de facturación configurada",
                    "reason_code": "missing_series",
                    "invoice_id": null,
                    "uuid": null,
                    "total": null,
                    "error": null,
                    "error_code": null
                },
                "certificate": {
                    "planned": true,
                    "status": "failed",
                    "reason": null,
                    "reason_code": null,
                    "invoice_id": null,
                    "uuid": null,
                    "total": null,
                    "error": "Timbrado interrumpido: verificar en el PAC antes de reintentar",
                    "error_code": "interrupted_stamp"
                }
            }
        ],
        "next": "4",
        "has_more": true
    },
    "timestamp": 1788224160000
}
```

The third movement had only its certificate planned (the provider's team has no invoice series, so the income invoice is skipped with `reason_code: "missing_series"`). A movement is `excluded` only when **neither** document is planned; then `exclusion_codes` lists the distinct codes of both.

Commission invoices are not listed per provider-month; the run only reports how many were planned (`planned_documents.commission`), stamped (`progress.commission_invoices_count`) and failed (`progress.commission_failed_count`).

### Confirm a Run

```http theme={null}
POST /platform-payouts/{id}/confirm
```

> **Irreversible.** Confirming hands the plan to the stamping worker. A stamped CFDI can only be cancelled, not undone. Review the plan first.

No request body. Answers as soon as the run is handed over; the stamping itself happens in the background (the worker runs every minute).

```bash theme={null}
curl -X POST https://api.gigstack.io/v2/platform-payouts/batchrun_3f9a1c7e2b4d6f8a0c1e3a5b7d9f1a2c/confirm \
  -H "Authorization: Bearer YOUR_TOKEN"
```

```json theme={null}
{
    "success": true,
    "data": {
        "id": "batchrun_3f9a1c7e2b4d6f8a0c1e3a5b7d9f1a2c",
        "status": "stamping"
    },
    "timestamp": 1788221400000
}
```

* **Safe to retry.** Confirming a run that is already `stamping` or `completed` returns `200` with its current status and does nothing else.
* Only a `plan_ready` run with at least one planned document can be confirmed. Otherwise: `409 plan_not_ready` (still planning), `409 not_confirmable` (`plan_failed` or `failed`), `409 nothing_to_stamp` (every document was excluded).
* The entitlement is checked again: if platform payouts was disabled on the billing account since the upload, the confirm is refused with `403 feature_disabled`.

#### Provider-months already certified

A provider's monthly commission is prorated over the movements of **this** file. If an earlier run already certified the same provider and month, certifying it again would report the platform commission twice to the SAT. So, when you confirm, the run reserves every provider-month it certifies:

* A provider-month held by an **earlier confirmed run** of your team (same mode) is given up: those movements' `certificate` becomes `skipped`, with `reason_code: "certificate_month_reserved"` and a reason naming the earlier run (`La corrida batchrun_… ya reservó las constancias de 2026-08 para este proveedor`).
* The run's `included_count`, `excluded_count`, `exclusion_summary`, `exclusion_code_summary` and `planned_documents.certificate` are recomputed to match. A movement left with nothing to issue becomes `excluded`, with `exclusion_codes: ["certificate_month_reserved"]`.
* Income and commission invoices are not affected.

Provider-months that an earlier run had already reserved when this one was **planned** are excluded at planning time instead, with the same code and the reason `La corrida … ya emitió constancias de … para este proveedor: vuelve a subir el mes completo o emítelas por separado`.

To certify a month correctly, upload **all** of the provider's movements for that month in one run.

#### Following progress

Poll `GET /platform-payouts/{id}` every 30-60 seconds. The worker stamps one document at a time, with a short pause between stamps, so a run of several hundred movements takes several minutes or more.

* `progress.stamped_count` / `progress.failed_count` count **movements**.
* `progress.income_invoices_count`, `certificates_count` and `commission_invoices_count` count **comprobantes**; `commission_failed_count` counts failed commission invoices. `income_invoices_amount` is the total of the stamped income invoices in MXN.
* When the run finishes, read [`result`](#run-result). If it is `partially_completed` or `failed`, list the movements and look for `status: "failed"`, the document's `error` (Spanish, for people) and its `error_code` (for programs).
* A transient failure (for example the PAC being unavailable, `error_code` `NETWORK_ERROR` or `HTTP_503`) is retried, up to 3 attempts per document; while it waits, the document stays `planned` with the last `error` and `error_code`. A SAT rejection is final; its `error_code` is the CFDI error code (for example `STAMPING_ERROR`).
* A stamp that was sent to the PAC but never recorded fails with `error_code: "interrupted_stamp"` and `Timbrado interrumpido: verificar en el PAC antes de reintentar`. It is not retried automatically, because retrying could issue a second CFDI for the same payout. Contact support.
* If a provider's team was moved out of your billing account, or scheduled for deletion, after the plan was made, its documents fail with `error_code: "team_out_of_scope"` and nothing is issued for it (`La cuenta del proveedor ya no pertenece a esta cuenta de facturación: no se emitió nada.` or `La cuenta del proveedor está programada para eliminarse: no se emitió nada.`). If the team was deleted, the `error` is `La cuenta del proveedor ya no existe en Gigstack.`

**Finding the stamped documents.** Each document's `uuid` is the SAT folio fiscal. For a `certificate`, `invoice_id` is the retention's id, readable with [`GET /retentions/{id}`](/guides/retentions#get-retention). For `income`, `invoice_id` is an invoice of the **provider's** team (`provider.team`), not of yours; read it with the [`team` parameter](/guides/gigstack-connect) if your plan includes gigstack Connect.

## End-to-End Example

Use Bash, `curl` and `jq`, with `GIGSTACK_API_KEY` loaded locally and the input files
reviewed for the intended team and mode. These scripts do not retry failed writes
automatically. An HTTP or JSON error stops the script; reconcile any ambiguous write
before starting again.

```bash theme={null}
set -euo pipefail
TOKEN="${GIGSTACK_API_KEY:?Load your API key locally first}"
BASE="https://api.gigstack.io/v2/platform-payouts"

# 1. Upload and plan. Keep the key: retrying with it is safe.
RUN_ID=$(curl --fail-with-body --silent --show-error -X POST "$BASE" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: payouts-2026-08-v1" \
  -F "movements_file=@movimientos-agosto-2026.csv" \
  -F "commissions_file=@comisiones-agosto-2026.xlsx" | jq -er '.data.id | select(type == "string" and length > 0)')

# 2. Wait for the plan (only needed if the upload was retried while planning).
for attempt in {1..120}; do
  RUN=$(curl --fail-with-body --silent --show-error "$BASE/$RUN_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$RUN" | jq -er '.data.status')
  [ "$STATUS" != "planning" ] && break
  sleep 5
done
[ "$STATUS" != "planning" ] || { echo "Planning still pending; save the run ID and reconcile" >&2; exit 1; }
if [ "$STATUS" = "plan_failed" ]; then
  echo "$RUN" | jq -r '.data.error'   # fix the file, upload again with a new key
  exit 1
fi

# 3. Review the plan: totals, then every excluded movement.
echo "$RUN" | jq '.data | {total_movements, included_count, planned_documents, exclusion_code_summary}'
NEXT=""
while true; do
  PAGE=$(curl --fail-with-body --silent --show-error "$BASE/$RUN_ID/movements?limit=100${NEXT:+&next=$NEXT}" -H "Authorization: Bearer $TOKEN")
  echo "$PAGE" | jq -r '.data.data[] | select(.status == "excluded") | "\(.line)\t\(.provider.id)\t\(.exclusion_codes | join(","))\t\(.exclusion_reason)"'
  [ "$(echo "$PAGE" | jq -r '.data.has_more')" = "true" ] || break
  NEXT=$(echo "$PAGE" | jq -r '.data.next')
done

# Save RUN_ID for the next step. Stop here and review the plan.
echo "Run to review: $RUN_ID"
```

**Review before proceeding:** check totals, provider identity, every exclusion and
planned fiscal document. Confirming starts fiscal issuance. Run the next block only
for the intended reviewed run, in the same shell (or restore its `RUN_ID` and `BASE`).
A `plan_failed`, empty or unexpected plan is not ready to confirm.

```bash theme={null}
# 4. Confirm the reviewed plan (irreversible). Retrying this confirmation is safe.
[ "$STATUS" = "plan_ready" ] || { echo "Plan is not ready for confirmation" >&2; exit 1; }
curl --fail-with-body --silent --show-error -X POST "$BASE/$RUN_ID/confirm" -H "Authorization: Bearer $TOKEN" | jq '.data'

# 5. Poll until the worker finishes.
for attempt in {1..120}; do
  RUN=$(curl --fail-with-body --silent --show-error "$BASE/$RUN_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$RUN" | jq -er '.data.status')
  echo "$RUN" | jq -c '.data.progress'
  [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ] && break
  sleep 60
done
case "$STATUS" in completed|failed) ;; *) echo "Worker is still pending; save the run ID and reconcile" >&2; exit 1 ;; esac
echo "$RUN" | jq '.data | {status, result, error, progress}'
# result: completed, partially_completed (list the failed movements) or failed
```

## Response Objects

### Run

| Field | Type | Description |
| - | - | - |
| `id` | string | `batchrun_…`. Derived from team, mode and `Idempotency-Key` |
| `status` | string | See [Run Status](#run-status) |
| `result` | string \| null | `completed`, `partially_completed` or `failed` once the run finishes, `null` before. See [Run Result](#run-result) |
| `livemode` | boolean | Mode of the credential that created the run |
| `team` | string | Your master team |
| `created_at` | integer | Epoch ms |
| `plan_ready_at` | integer \| null | When planning ended (ready or failed) |
| `confirmed_at` | integer \| null | When the run was confirmed |
| `completed_at` | integer \| null | When the run became `completed` or `failed` |
| `files.movements`, `files.commissions` | string | Uploaded file names |
| `months` | string\[] | `YYYY-MM` months in the movements file, ascending |
| `total_movements` | integer | Rows in the movements file |
| `included_count` | integer | Movements with at least one document to issue |
| `excluded_count` | integer | Movements with nothing to issue |
| `planned_documents` | object | `income`, `certificate`, `commission`: CFDIs the plan issues |
| `exclusion_summary` | object | Excluded movements counted by reason, for people. Keys are Spanish reason text that may be reworded |
| `exclusion_code_summary` | object | Excluded movements counted by [exclusion code](#exclusion-codes), for programs. Only codes that occur are present. A movement with two codes counts under each, so the values can add up to more than `excluded_count` |
| `progress` | object | `stamped_count`, `failed_count` (movements); `income_invoices_count`, `certificates_count`, `commission_invoices_count` (documents stamped); `commission_failed_count` (commission invoices failed); `income_invoices_amount` (MXN) |
| `error` | string \| null | Spanish reason for `plan_failed` / `failed` |

### Movement

| Field | Type | Description |
| - | - | - |
| `id` | string | Unique within the run. Treat as opaque |
| `line` | integer | Row in the movements file (header is line 1) |
| `status` | string | `planned`, `excluded`, `stamped` (all planned documents issued), `failed` (at least one failed) |
| `provider` | object | `id` (`ID del proveedor`), `name` and `tax_id` (from the matched team, else from the file), `email`, `team` (matched team id or `null`) |
| `movement_type` | string | `Tipo de movimiento` |
| `date` | string | `YYYY-MM-DD`; empty if the file's date was invalid |
| `month` | string | `YYYY-MM` |
| `subtotal` | number | MXN, rounded to the cent for display |
| `commission` | number | Prorated share of the monthly commission, MXN |
| `exclusion_reason` | string \| null | Spanish reason when `status` is `excluded` (two reasons are joined with `·`) |
| `exclusion_codes` | string\[] | The distinct [exclusion codes](#exclusion-codes) behind `exclusion_reason`. Empty when the movement isn't excluded |
| `error` | string \| null | Last stamping error, Spanish |
| `income`, `certificate` | object | Document state, below |

### Document state (`income`, `certificate`)

| Field | Type | Description |
| - | - | - |
| `planned` | boolean | Whether the plan issues it |
| `status` | string | `planned`, `skipped`, `stamped`, `failed` |
| `reason` | string \| null | Why it isn't planned, in Spanish |
| `reason_code` | string \| null | Why it isn't planned, as an [exclusion code](#exclusion-codes). `null` when planned |
| `invoice_id` | string \| null | Stamped document id (see [Following progress](#following-progress)) |
| `uuid` | string \| null | SAT folio fiscal |
| `total` | number \| null | Income invoice total, MXN. `null` for certificates |
| `error` | string \| null | Last stamping error, in Spanish |
| `error_code` | string \| null | The stamping or PAC error code behind `error`: `interrupted_stamp`, `NETWORK_ERROR`, `HTTP_<status>` (e.g. `HTTP_503`), `team_out_of_scope`, or a CFDI error code (e.g. `STAMPING_ERROR`, `PAC_UNAVAILABLE`; see [CFDI Errors](/guides/catalogs/cfdi_errors)). `null` when there is no error |

### Exclusion codes

Every reason a document isn't planned comes with a **stable code**: `reason_code` on the document, `exclusion_codes` on an excluded movement, and the keys of the run's `exclusion_code_summary`. **Branch on the codes.** Codes are never renamed (new ones may be added); the Spanish sentences in `reason`, `exclusion_reason` and `exclusion_summary` are for people and may be reworded at any time.

| Code | Documents | Meaning | Spanish reason (today) |
| - | - | - | - |
| `invalid_row` | All | The row itself is malformed (bad date, amount or month) | The row problem, e.g. `Fecha del movimiento inválida (…)`, `Subtotal inválido (…)`, `El subtotal debe ser de al menos 0.01` |
| `provider_not_found` | Income, certificate | No gigstack team of your billing account matches the provider | `El proveedor no tiene cuenta en Gigstack` |
| `missing_tax_id` | Income, commission | No RFC: neither on the team nor in the file (commission: none in the commissions file) | `Sin RFC`, `Sin RFC en el archivo` |
| `missing_legal_name` | Income, certificate | The provider's team has no legal name (certificate: neither the team nor the file has one) | `Sin razón social en Gigstack`, `Sin razón social` |
| `missing_zip` | Income, certificate | The provider's team has no fiscal zip code | `Sin código postal fiscal` |
| `missing_fiscal_data` | Commission | No provider team, or it lacks legal name or fiscal zip code | `Sin datos fiscales en Gigstack` |
| `csd_expired` | All | The provider's CSD (sellos) in gigstack expired | `Sellos (CSD) vencidos en Gigstack` |
| `missing_csd` | All | The provider's team has no CSD in gigstack | `Sin sellos (CSD) en Gigstack` |
| `tax_system_not_allowed` | All | The provider's tax regime isn't allowed by your [tax policy](#tax-policy) | `Régimen …, se pidió emitir solo a 625` |
| `duplicate_tax_id` | Income, certificate | The RFC matches more than one team of your billing account | `El RFC está en más de una cuenta: …` |
| `name_mismatch` | Income | `Nombre del proveedor` differs from the team's legal name (SAT registry check) | `El nombre del archivo no coincide con el de la cuenta ("…")` |
| `missing_series` | Income, commission | The issuing team has no invoice series: the provider's team for income, your master team for commissions | `Sin serie de facturación configurada` |
| `public_general_not_allowed` | Certificate | The certificate would need the generic RFC and your tax policy forbids issuing to the general public | `Requeriría RFC genérico y se pidió no emitir al público en general` |
| `certificate_month_reserved` | Certificate | An earlier run already certified this provider-month (at planning or at confirm, see [Provider-months already certified](#provider-months-already-certified)) | `La corrida … ya emitió constancias de …` / `La corrida … ya reservó las constancias de …` |
| `missing_commission` | Certificate | No commission for the month, so the SAT would reject the certificate (`SPT147`) | `Falta el archivo de comisiones de 2026-08: …` |
| `zero_commission` | Commission | The month's commission in the commissions file is zero | `La comisión del mes es cero` |

## Error Handling

All errors use the standardized envelope. Messages are in Spanish (a few validation messages are in English); **branch on `error.code`**, not on the message.

```json theme={null}
{
    "success": false,
    "error": {
        "code": "plan_not_ready",
        "message": "El plan todavía se está calculando."
    },
    "timestamp": 1788221400000
}
```

Authentication failures (`401`, and the `403` for a revoked key or a plan without API access) come from the authentication layer with a raw `{ "message": … }` body; see [Authentication errors](/guides/welcome#authentication-errors).

| Status | `error.code` | Endpoints | When |
| - | - | - | - |
| `400` | `invalid_request_body` | `POST /platform-payouts` | `Idempotency-Key` missing or malformed |
| `400` | `invalid_content_type` | `POST /platform-payouts` | Body isn't `multipart/form-data` |
| `400` | `file_required` | `POST /platform-payouts` | `movements_file` or `commissions_file` missing (`details` names it) |
| `400` | `empty_file` | `POST /platform-payouts` | A file is zero bytes |
| `400` | `invalid_file_format` | `POST /platform-payouts` | Extension isn't `.csv`, `.txt`, `.xlsx`, `.xls`, `.xlsm` |
| `400` | `unexpected_file` | `POST /platform-payouts` | An extra file field, a field sent twice, or more than two files |
| `400` | `too_many_fields` | `POST /platform-payouts` | More than 10 plain form fields |
| `400` | `file_upload_error` | `POST /platform-payouts` | The multipart body couldn't be parsed |
| `400` | `invalid_limit` | `GET /{id}/movements` | `limit` not an integer 1-100 |
| `400` | `invalid_cursor` | `GET /{id}/movements` | `next` isn't a valid cursor |
| `401` | `unauthorized` | All | Missing or invalid credential |
| `403` | `not_master_team` | `POST /platform-payouts`, `POST /{id}/confirm` | Team isn't a marketplace master team (also with Connect's `team`) |
| `403` | `no_billing_account` | `POST /platform-payouts`, `POST /{id}/confirm` | Team has no billing account |
| `403` | `feature_disabled` | `POST /platform-payouts`, `POST /{id}/confirm` | Platform payouts isn't enabled on the billing account |
| `403` | `team_not_found` | `POST /platform-payouts`, `POST /{id}/confirm` | The team document doesn't exist |
| `403` | `not_a_member` | `POST /platform-payouts`, `POST /{id}/confirm` | User-scoped token, user isn't a team member |
| `403` | `forbidden` | `POST /platform-payouts`, `POST /{id}/confirm` | User-scoped token: not a member, or no `editor` permission on invoices |
| `404` | `run_not_found` | `GET /{id}`, `GET /{id}/movements`, `POST /{id}/confirm` | No such run in your team and mode (includes malformed ids) |
| `404` | `resource_not_found` | `GET /{id}`, `GET /{id}/movements`, `POST /{id}/confirm` | User-scoped token whose user isn't a member of the team |
| `409` | `idempotency_key_reused` | `POST /platform-payouts` | The `Idempotency-Key` was already used with different file contents. Use a new key for new files |
| `409` | `run_exists` | `POST /platform-payouts` | The derived run id is held by another team's run. Not expected in practice; use another key |
| `409` | `plan_not_ready` | `POST /{id}/confirm` | Run is still `planning` |
| `409` | `not_confirmable` | `POST /{id}/confirm` | Run is `plan_failed` or `failed` |
| `409` | `nothing_to_stamp` | `POST /{id}/confirm` | The plan issues no documents |
| `413` | `file_too_large` | `POST /platform-payouts` | A file exceeds 5 MB |
| `500` | `internal_server_error` | All | Unexpected failure. Retrying (with the same `Idempotency-Key` for uploads) is safe |

A problem **inside** a file isn't an HTTP error: the upload answers `201` with `status: "plan_failed"` and the reason in `data.error`.

## Best Practices

1. **Use one `Idempotency-Key` per upload** and reuse it only to retry that same upload (same file contents). After fixing a file, use a new key; reusing the old one is `409 idempotency_key_reused`.
2. **Review before confirming.** Check `exclusion_code_summary` and list the excluded movements; fix provider data in gigstack or the files and upload again rather than confirming a plan with surprises.
3. **Upload whole months.** Certificates prorate the monthly commission over the movements in the file. A provider-month split across runs is only certified by the first.
4. **Include the commissions for every month** in the movements file, or those movements get no certificate.
5. **Try it with a test key first.** Test runs are separate from live runs.
6. **Poll gently.** Every 30-60 seconds is enough; the worker runs once a minute.
7. **Read `result`, not `status`**, when the run finishes. On `partially_completed` or `failed`, list the failed movements and branch on each document's `error_code`.
8. **Branch on codes, not sentences**: `error.code`, `reason_code`, `exclusion_codes`, `error_code`.

## Related Resources

* [Retentions API](/guides/retentions) - Read the retention certificates a run issued
* [Invoices API](/guides/invoices) - Income and commission invoices
* [gigstack Connect](/guides/gigstack-connect) - Read documents of the providers' teams
* [CFDI Errors Reference](/guides/catalogs/cfdi_errors) - Stamping errors you may see in a document's `error`
* [Test Mode](/guides/welcome#test-mode)

***

For additional assistance, contact [support@gigstack.io](mailto:support@gigstack.io)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.