Skip to main content

Pix out

2 operations under /v1/consumers/{consumerId}/pix/devolutions (POST GET): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.

8 min read
View MarkdownEdit on GitHub

Base URL: https://api.codespar.dev

Every operation below requires a Bearer token. See Authentication.

POST /v1/consumers/{consumerId}/pix/devolutions

POSThttps://api.codespar.dev/v1/consumers/{consumerId}/pix/devolutions
Moves money

Returns money on the Pix rail itself

Reverse a Pix, in either direction

Returns money on the Pix rail itself, keyed on the ORIGINAL payment's endToEndId. This is not a charge refund and it does not go through a charge: the rail reverses the transfer, and the consumer's governed wallet is mirrored to match.

WHICH WAY THE MONEY GOES IS payment_direction, AND IT IS EASY TO GET BACKWARDS. It describes the ORIGINAL payment from the consumer's point of view, not the movement you are asking for. inbound (the default) means the consumer RECEIVED that payment, so the devolution sends it back to whoever paid. outbound means the consumer SENT it, so the devolution brings the money BACK to the consumer. Sending the wrong one does not fail loudly — it reverses a real payment in the wrong direction — so set it explicitly rather than leaning on the default.

idempotency_key IS REQUIRED, and it is the only thing standing between a retry and a second reversal. The server mints its own client_code from it for the provider, so the correlation survives even a response you never saw. Reuse the same key for the same intent and a replay comes back as 200 with idempotent_replay: true; a first dispatch is 201. Reusing one key for a DIFFERENT devolution is refused with idempotency_key_conflict rather than silently accepted.

THE AMOUNT IS OPTIONAL IN EXACTLY ONE CASE. When an inbound payment credited a governed wallet, omitting amount_minor returns the remaining reversible amount, computed from the consumer's own statement. Everywhere else — and on every outbound devolution — it is required, and omitting it is amount_required. A partial reversal is allowed; asking for more than was received is devolution_amount_exceeds_received.

TENANCY IS PROVED FROM THE PROVIDER, NOT FROM OUR TABLES. The original payment has to appear on this consumer's own account statement before anything is dispatched, which is why an unknown or foreign end_to_end_id answers devolution_source_not_found rather than a permission error. It also means the call FAILS CLOSED when the statement cannot be read: statement_unavailable with 503, never a dispatch on an unverified reference.

A 502 psp_dispatch_uncertain IS THE ONE THAT NEEDS A HUMAN-SAFE RULE. It means the request may have reached the provider and the answer did not come back — a timeout, a reset, an unparseable body. Money may have moved. The safe move is to re-send the SAME idempotency_key, which either replays the recorded outcome or resolves it; it is never to issue a second devolution with a new key.

SLOW, AND DELIBERATELY SO. A statement read precedes the dispatch, and the dispatch is a real rail call. Allow for it rather than timing out into a retry you did not mean.

Path parameters

NameTypeRequiredDescription
consumerIdstringyesThe consumer whose account the original payment touched. It is also what binds the statement lookup.

Request body

FieldTypeRequiredDescription
amount_minorintegerno—
descriptionstringno—
end_to_end_idstringyes—
idempotency_keystringyes—
payment_direction"inbound" | "outbound"no—
reasonstringno—

Responses

StatusBodyDescription
200objectOK
201objectOK
400objectThe request could not be acted on as written. invalid_body carries Zod's issues in details.issues; the rest are checks the schema cannot express, raised before anything is claimed or dispatched.
404objectThe original payment is not on this consumer's statement. The same answer covers a reference that never existed and one that belongs to another consumer, org or project: telling those apart would leak whether a given end-to-end id exists.
409objectThe reversal cannot proceed in the state the world is in, and the code says which state. devolution_already_reversed and devolution_in_progress mean another devolution has the claim; idempotency_key_conflict means this key was used for a different devolution; insufficient_funds and devolution_ledger_exhausted mean the governed wallet cannot cover the mirror; funding_source_unavailable, devolution_correlation_unavailable, devolution_ledger_unavailable and devolution_charge_unsettled mean a precondition on our side is not resolvable right now. None of these dispatched anything.
422objectThe request is well formed and the rail will not do it. devolution_amount_exceeds_received means you asked for more than the statement supports; devolution_refused is the provider's own 4xx, which proves NO reversal was minted.
500objectThe wallet mirror could not be written after the rail leg, or the handler failed in a way it does not classify. wallet_reverse_failed is the specific one and it means the ledger and the rail may disagree until an operator reconciles.
502objectThe dispatch may or may not have reached the provider. MONEY MAY HAVE MOVED. Re-send the same idempotency_key; do not issue a new one.
503objectA precondition could not be read, so the call failed closed rather than dispatching on an unverified reference. statement_unavailable is the consumer's own account statement; credential_unavailable is the provider credential. Both are retryable and neither sent anything.

Response 200

FieldTypeRequiredDescription
amount_minorintegeryesWhat is actually being returned, in centavos. On an inbound devolution that omitted the amount, this is the remaining reversible amount the statement supported, which may be less than the original payment if part of it was already returned.
charge_idstring,nullyes—
client_codestringyesThe correlation the server minted and sent upstream. Derived from your idempotency_key, not equal to it: this is the value to quote to the provider, and yours is the value to send back here.
created_atstring (date-time)yes—
currency"BRL"yes—
devolution_idstringyes—
end_to_end_idstringyesThe original payment being reversed, echoed back.
failure_reasonstring,nullyesPopulated on failed; null otherwise.
idempotent_replaybooleanyesTrue when this response replays an outcome recorded earlier for the same idempotency key. It travels with the 200; a first dispatch answers 201.
original_payment_idstring,nullyes—
payment_direction"inbound" | "outbound"yesEchoes which side of the rail the ORIGINAL payment was on, so a client that omitted the field can see what the default resolved to.
reasonstringyesThe Pix return code that was used, defaulted if you sent none.
return_identificationstring,nullyesThe provider's own id for the reversal. Null until the provider has accepted it.
settled_atstring,null (date-time)yesSet only once the consumer's own statement showed the reversal. A 202-like optimism has no place here: acceptance is not settlement.
status"pending" | "accepted" | "settled" | "failed" | "uncertain"yesThe five values the table's CHECK constraint admits. uncertain is the one to plan for: it means the reversal MAY have been sent and the answer did not say. Resolve it by reading the devolution back, never by reversing again.
status_messagestringyesFree text for an operator. Not a code to branch on.
wallet_idstring,nullyesThe governed wallet the mirror entry was written against, when the payment touched one.
Example request
curl -X POST https://api.codespar.dev/v1/consumers/{consumerId}/pix/devolutions \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "end_to_end_id": "endtoend_0000000000000000",
       "amount_minor": 1000,
       "idempotency_key": "string",
       "reason": "string",
       "description": "string",
       "payment_direction": "inbound"
     }'
POST /v1/consumers/{consumerId}/pix/devolutions HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "end_to_end_id": "endtoend_0000000000000000",
  "amount_minor": 1000,
  "idempotency_key": "string",
  "reason": "string",
  "description": "string",
  "payment_direction": "inbound"
}
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/consumers/{consumerId}/pix/devolutions",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "end_to_end_id": "endtoend_0000000000000000",
      "amount_minor": 1000,
      "idempotency_key": "string",
      "reason": "string",
      "description": "string",
      "payment_direction": "inbound"
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/consumers/{consumerId}/pix/devolutions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "end_to_end_id": "endtoend_0000000000000000",
    "amount_minor": 1000,
    "idempotency_key": "string",
    "reason": "string",
    "description": "string",
    "payment_direction": "inbound"
  }),
});

const data = await res.json();
const r = await cs.api.response("post", "/v1/consumers/{consumerId}/pix/devolutions", {
  path: {
    consumerId: "csm_0000000000000000"
  },
  body: {
    end_to_end_id: "endtoend_0000000000000000",
    amount_minor: 1000,
    idempotency_key: "string",
    reason: "string",
    description: "string",
    payment_direction: "inbound"
  }
});
// r.status is one of the documented statuses (200, 422),
// each with its own body shape in r.data; nothing here throws on 422.
if (r.ok) {
  console.log(r.data);
}
Example response 200
application/json
{
  "devolution_id": "devolution_0000000000000000",
  "status": "pending",
  "payment_direction": "inbound",
  "end_to_end_id": "endtoend_0000000000000000",
  "amount_minor": 1000,
  "currency": "BRL",
  "reason": "string",
  "client_code": "string",
  "return_identification": "string",
  "original_payment_id": "originalpayment_0000000000000000",
  "wallet_id": "wlt_0000000000000000",
  "charge_id": "charge_0000000000000000",
  "failure_reason": "string",
  "created_at": "2026-01-15T12:00:00.000Z",
  "settled_at": "2026-01-15T12:00:00.000Z",
  "idempotent_replay": true,
  "status_message": "string"
}

GET /v1/consumers/{consumerId}/pix/devolutions/{devolutionId}

GEThttps://api.codespar.dev/v1/consumers/{consumerId}/pix/devolutions/{devolutionId}

A Pix devolution's state

The recorded devolution, with settlement resolved from the consumer's own account statement when the recorded state is not yet terminal.

The statement is the only provider truth available here: the provider's status poll never resolves reversals, so reading the reversal movement on the consumer's own statement is what settles the row. A statement fetch that fails degrades to provider_truth: "unavailable" and returns the recorded state, never an error: the row itself is always answerable.

A devolution belonging to another organization, project or consumer answers the same 404 an unknown id does.

Path parameters

NameTypeRequiredDescription
consumerIdstringyes—
devolutionIdstringyes—

Responses

StatusBodyDescription
200objectOK
404objectNot a known devolution for this consumer. The same opaque answer for a truly unknown id and for another tenant's.
500objectThe devolution status could not be read.

Response 200

FieldTypeRequiredDescription
amount_minorintegeryes—
charge_idstring,nullyes—
client_codestringyesThe server-minted idempotency correlation sent upstream.
created_atstring (date-time)yes—
currency"BRL"yes—
devolution_idstringyes—
end_to_end_idstringyes—
failure_reasonstring,nullyes—
idempotent_replaybooleanyesAlways false on this read. The field is meaningful on the create, not here.
original_payment_idstring,nullyes—
payment_direction"inbound" | "outbound"yesWhich side of the rail the ORIGINAL payment was on. inbound: the consumer received it and the money goes back to its payer. outbound: the consumer sent it and the money returns to the consumer.
provider_truth"statement" | "unavailable" | "not_checked"yesWhere the state above came from. not_checked: the row is already terminal. statement: the consumer's own account statement was read and settled the row if it showed the reversal. unavailable: the statement could not be fetched, so the recorded state is returned unchanged. A statement failure degrades this field; it never turns the read into an error.
reasonstringyesThe Pix return code.
return_identificationstring,nullyes—
settled_atstring,null (date-time)yes—
status"pending" | "accepted" | "settled" | "failed" | "uncertain"yesThe five values the table's CHECK constraint admits.
status_messagestringyes—
wallet_idstring,nullyes—
Example request
curl -X GET https://api.codespar.dev/v1/consumers/{consumerId}/pix/devolutions/{devolutionId} \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/consumers/{consumerId}/pix/devolutions/{devolutionId} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/consumers/{consumerId}/pix/devolutions/{devolutionId}",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/consumers/{consumerId}/pix/devolutions/{devolutionId}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/consumers/{consumerId}/pix/devolutions/{devolutionId}", {
  path: {
    consumerId: "csm_0000000000000000",
    devolutionId: "devolution_0000000000000000"
  }
});
Example response 200
application/json
{
  "devolution_id": "devolution_0000000000000000",
  "status": "pending",
  "payment_direction": "inbound",
  "end_to_end_id": "endtoend_0000000000000000",
  "amount_minor": 1000,
  "currency": "BRL",
  "reason": "string",
  "client_code": "string",
  "return_identification": "string",
  "original_payment_id": "originalpayment_0000000000000000",
  "wallet_id": "wlt_0000000000000000",
  "charge_id": "charge_0000000000000000",
  "failure_reason": "string",
  "created_at": "2026-01-15T12:00:00.000Z",
  "settled_at": "2026-01-15T12:00:00.000Z",
  "idempotent_replay": true,
  "status_message": "string",
  "provider_truth": "statement"
}
Pix out | CodeSpar