curl --request POST \
--url https://api.example.com/invoices/{uuid}/cancel \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"motivo": "02",
"folioSustitucion": "d3bfbc57-44af-4390-a064-f0afab85e5df"
}
'import requests
url = "https://api.example.com/invoices/{uuid}/cancel"
payload = {
"motivo": "02",
"folioSustitucion": "d3bfbc57-44af-4390-a064-f0afab85e5df"
}
headers = {
"X-API-Key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'X-API-Key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({motivo: '02', folioSustitucion: 'd3bfbc57-44af-4390-a064-f0afab85e5df'})
};
fetch('https://api.example.com/invoices/{uuid}/cancel', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/invoices/{uuid}/cancel",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'motivo' => '02',
'folioSustitucion' => 'd3bfbc57-44af-4390-a064-f0afab85e5df'
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.example.com/invoices/{uuid}/cancel"
payload := strings.NewReader("{\n \"motivo\": \"02\",\n \"folioSustitucion\": \"d3bfbc57-44af-4390-a064-f0afab85e5df\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.example.com/invoices/{uuid}/cancel")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"motivo\": \"02\",\n \"folioSustitucion\": \"d3bfbc57-44af-4390-a064-f0afab85e5df\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.example.com/invoices/{uuid}/cancel")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"motivo\": \"02\",\n \"folioSustitucion\": \"d3bfbc57-44af-4390-a064-f0afab85e5df\"\n}"
response = http.request(request)
puts response.read_body{
"id": "<string>",
"invoiceId": "<string>",
"motivo": "01",
"requiresApproval": true,
"cancellationStatus": "pendiente",
"createdAt": "2023-11-07T05:31:56Z",
"folioSustitucion": {},
"respondBy": {},
"requestedBy": {},
"resolvedBy": {},
"resolvedAt": {}
}Cancel Invoice
Authenticate with either a Supabase session (member of the invoice’s organization) or an API key belonging to that same organization. Determines automatically whether the cancellation is direct or requires receptor approval per SAT rules — Timbrix cannot confirm receptor approval in real time, so a pending cancellation must be resolved manually via the resolve endpoint.
curl --request POST \
--url https://api.example.com/invoices/{uuid}/cancel \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"motivo": "02",
"folioSustitucion": "d3bfbc57-44af-4390-a064-f0afab85e5df"
}
'import requests
url = "https://api.example.com/invoices/{uuid}/cancel"
payload = {
"motivo": "02",
"folioSustitucion": "d3bfbc57-44af-4390-a064-f0afab85e5df"
}
headers = {
"X-API-Key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'X-API-Key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({motivo: '02', folioSustitucion: 'd3bfbc57-44af-4390-a064-f0afab85e5df'})
};
fetch('https://api.example.com/invoices/{uuid}/cancel', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/invoices/{uuid}/cancel",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'motivo' => '02',
'folioSustitucion' => 'd3bfbc57-44af-4390-a064-f0afab85e5df'
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.example.com/invoices/{uuid}/cancel"
payload := strings.NewReader("{\n \"motivo\": \"02\",\n \"folioSustitucion\": \"d3bfbc57-44af-4390-a064-f0afab85e5df\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.example.com/invoices/{uuid}/cancel")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"motivo\": \"02\",\n \"folioSustitucion\": \"d3bfbc57-44af-4390-a064-f0afab85e5df\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.example.com/invoices/{uuid}/cancel")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"motivo\": \"02\",\n \"folioSustitucion\": \"d3bfbc57-44af-4390-a064-f0afab85e5df\"\n}"
response = http.request(request)
puts response.read_body{
"id": "<string>",
"invoiceId": "<string>",
"motivo": "01",
"requiresApproval": true,
"cancellationStatus": "pendiente",
"createdAt": "2023-11-07T05:31:56Z",
"folioSustitucion": {},
"respondBy": {},
"requestedBy": {},
"resolvedBy": {},
"resolvedAt": {}
}X-Organization-Id header), this route resolves the organization from the invoice’s own uuid — its folio fiscal, globally unique across all organizations — combined with the caller’s own auth context (session or API key), so no organization identifier needs to be supplied separately at all.
Authentication
Accepts either:- A Supabase Bearer session (
Authorization: Bearer <token>) — the authenticated user must be a member of the invoice’s organization. - An API key (
X-API-Key: sk_...) with thewrite:invoicesscope — the key’s own organization must match the invoice’s organization, or the request is rejected with403 Forbidden.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
uuid | string (UUID) | Yes | Folio fiscal UUID (uuidFiscal) of the invoice to cancel — globally unique, not scoped to an organization |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
motivo | string | Yes | SAT cancellation reason code — 01, 02, 03, or 04 (see below) |
folioSustitucion | string | Conditional* | Folio fiscal UUID of the CFDI that replaces this one. Required, and only meaningful, when motivo is 01 |
motivo is 01 (sustitución). Must be the folio fiscal of an existing, vigente invoice belonging to the same organization, and cannot be the invoice being cancelled itself — an arbitrary or unverified UUID is rejected with a 400. It must also be a CFDI issued with relatedCfdis: [{ "type": "04", "uuids": ["<this invoice's UUID>"] }] and dated no earlier than the invoice being cancelled (see Substitution flow). Ignored for motivo 02, 03, and 04, even if sent.
motivo codes
| Code | Description | folioSustitucion |
|---|---|---|
01 | Emitido con errores con relación (sustitución) | Required |
02 | Emitido con errores sin relación | Not applicable |
03 | No se llevó a cabo la operación | Not applicable |
04 | Operación nominativa relacionada en factura global | Not applicable |
Substitution flow (motivo 01)
The SAT only acceptsmotivo: "01" when the replacement CFDI relates the original with TipoRelacion 04 and is not dated before it. Timbrix checks both against the stamped XML before calling the PAC. Order matters: first stamp the replacement (B), then cancel the original (A).
- Issue B, relating A with
relatedCfdis(see Create Invoice):
curl -X POST https://api.timbrix.mx/invoices \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"series": "A",
"folioNumber": "2",
"date": "2026-07-30T16:10:00",
"paymentForm": "01",
"use": "G03",
"relatedCfdis": [
{ "type": "04", "uuids": ["d3bfbc57-44af-4390-a064-f0afab85e5df"] }
],
"customerId": "590ce6c56d04f840aa8438af",
"items": [ { "quantity": 1, "description": "Servicio de consultoría", "unitPrice": 100, "amount": 100, "productKey": "84111506", "unitKey": "E48" } ]
}'
- Cancel A with
motivo: "01"andfolioSustitucionset to the UUID returned for B:
curl -X POST https://api.timbrix.mx/invoices/d3bfbc57-44af-4390-a064-f0afab85e5df/cancel \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"motivo": "01",
"folioSustitucion": "a1b2c3d4-5e6f-4890-9abc-def012345678"
}'
400 before reaching the SAT. These messages replace the SAT rejections 312 and 208:
- B does not relate A with
TipoRelacion 04(SAT code 312):La factura sustituta <serie>-<folio> no relaciona a esta factura como sustitución (TipoRelacion 04). El SAT rechazaría la cancelación (código 312). Emite la factura sustituta con "Sustituir factura". - B is dated earlier than A (SAT code 208):
La factura sustituta no puede tener fecha anterior a la original (el SAT la rechazaría con el código 208)
400/404 messages for folioSustitucion: La factura sustituta no puede ser la misma factura que se está cancelando, La factura sustituta debe estar vigente, La factura sustituta pertenece a otro ambiente (sandbox/producción), No se pudo leer el CFDI sustituto, and Factura sustituta <uuid> no encontrada (404).
How eligibility is determined
Timbrix computes, from the invoice’s own data, whether the cancellation applies directly or requires the receptor’s approval — this follows the SAT’s own rule and you never send it yourself; it’s returned in the response asrequiresApproval. The cancellation is direct (no approval needed) when at least one of these is true:
tipoComprobanteisE(Egreso) orT(Traslado)totalis ≤ $5,000 MXNrfcReceptorisXAXX010101000(público en general) orXEXX010101000(residente extranjero)- The invoice was issued 3 business days ago or less (Mon–Fri, America/Mexico_City calendar)
cancellationStatus: "pendiente" and a respondBy deadline — the invoice stays vigente until
you confirm the actual outcome yourself, after checking the SAT portal, via
Resolve Cancellation.cancellationStatus: "aceptada" and the invoice’s status flips to cancelado immediately.
Mass cancellation limit
Organizations on the Starter or Pro plan are limited to 50 cancellation requests per calendar day; the 51st request that day returns400 Bad Request. Business and Enterprise plans have no daily limit.
Example Request
curl -X POST https://api.timbrix.mx/invoices/d3bfbc57-44af-4390-a064-f0afab85e5df/cancel \
-H "Authorization: Bearer <your_token>" \
-H "Content-Type: application/json" \
-d '{
"motivo": "02"
}'
// Not yet available in @timbrix/sdk — call the REST endpoint directly
// until SDK support for cancellations ships.
const response = await fetch(
"https://api.timbrix.mx/invoices/d3bfbc57-44af-4390-a064-f0afab85e5df/cancel",
{
method: "POST",
headers: {
Authorization: "Bearer <your_token>",
"Content-Type": "application/json",
},
body: JSON.stringify({ motivo: "02" }),
}
)
const cancellation = await response.json()
console.log(cancellation.cancellationStatus)
motivo: "01") — replacing this CFDI with an already-stamped corrected one:
curl -X POST https://api.timbrix.mx/invoices/d3bfbc57-44af-4390-a064-f0afab85e5df/cancel \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"motivo": "01",
"folioSustitucion": "a1b2c3d4-5e6f-4890-9abc-def012345678"
}'
Example Response
Direct cancellation — resolved immediately:{
"id": "6a1b2c3d-4e5f-4890-9abc-def012345678",
"invoiceId": "0f2a1c3e-1a2b-4c3d-9e8f-1234567890ab",
"motivo": "02",
"folioSustitucion": null,
"requiresApproval": false,
"respondBy": null,
"cancellationStatus": "aceptada",
"requestedBy": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"resolvedBy": null,
"resolvedAt": "2026-08-09T15:50:03.412Z",
"createdAt": "2026-08-09T15:50:03.412Z"
}
{
"id": "6a1b2c3d-4e5f-4890-9abc-def012345678",
"invoiceId": "0f2a1c3e-1a2b-4c3d-9e8f-1234567890ab",
"motivo": "02",
"folioSustitucion": null,
"requiresApproval": true,
"respondBy": "2026-08-12T15:50:03.412Z",
"cancellationStatus": "pendiente",
"requestedBy": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"resolvedBy": null,
"resolvedAt": null,
"createdAt": "2026-08-09T15:50:03.412Z"
}
| Field | Type | Description |
|---|---|---|
id | string | Cancellation request ID |
invoiceId | string | Timbrix invoice record ID (not the folio fiscal UUID) |
motivo | string | The cancellation reason code sent in the request |
folioSustitucion | string | null | Folio fiscal UUID of the replacement CFDI — only set for motivo: "01" |
requiresApproval | boolean | Whether this cancellation needed receptor approval, computed server-side |
respondBy | string | null | Deadline (ISO 8601) for the receptor to respond — 3 business days from when the cancellation was requested. null for direct cancellations |
cancellationStatus | string | pendiente, aceptada, or rechazada |
requestedBy | string | null | Timbrix user ID who requested the cancellation |
resolvedBy | string | null | Timbrix user ID who confirmed the final outcome — null for direct cancellations, which resolve automatically |
resolvedAt | string | null | ISO 8601 timestamp the outcome was confirmed — null while pendiente |
createdAt | string | ISO 8601 timestamp the cancellation was requested |
Common Errors
400 Bad Request
motivo is invalid; folioSustitucion is missing when motivo is 01, is not a vigente invoice of the same organization, or is the same invoice being cancelled; the invoice is already cancelado; or the organization has hit the daily mass-cancellation limit (Starter/Pro plans).
{
"statusCode": 400,
"message": "`folioSustitucion` es requerido cuando `motivo` es 01 (sustitución)",
"error": "BAD_REQUEST"
}
401 Unauthorized
Missing or invalid Bearer token / API key.403 Forbidden
The authenticated user is not a member of the invoice’s organization, or the API key does not have thewrite:invoices scope / belongs to a different organization than the one that owns the invoice.
404 Not Found
uuid does not match any invoice, or (when motivo is 01) folioSustitucion does not match any invoice belonging to the same organization.
409 Conflict
A cancellation is alreadypendiente for this invoice. Resolve it via Resolve Cancellation — or wait for the receptor to respond — before requesting another.
{
"statusCode": 409,
"message": "Esta factura ya tiene una cancelación pendiente de resolución",
"error": "CONFLICT"
}
Authorizations
API Key for authentication (format: sk_...)
Path Parameters
Body
01 = emitido con errores con relación (requiere folioSustitucion), 02 = emitido con errores sin relación, 03 = no se llevó a cabo la operación, 04 = operación nominativa relacionada en factura global
01, 02, 03, 04 "02"
UUID fiscal del CFDI que sustituye a este. Requerido cuando motivo=01, debe ser una factura vigente de la misma organización.
"d3bfbc57-44af-4390-a064-f0afab85e5df"
Response
01, 02, 03, 04 pendiente, aceptada, rechazada