Stable error codes, webhook signature verification, and test-mode keys.
Every error response uses the same envelope. error.code is stable and machine-readable; error.message is human-readable and may change. Validation errors carry error.details mapping field → messages.
{
"data": null,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": { "contact_id": ["Required"] }
}
}| Code | HTTP | Description |
|---|---|---|
| BAD_REQUEST | 400 | The request is malformed — unparseable body, invalid parameter shape. |
| UNAUTHORIZED | 401 | Missing, invalid, expired or revoked API key. |
| SUBSCRIPTION_REQUIRED | 402 | The organization needs an active subscription for this operation. |
| PLAN_LIMIT_EXCEEDED | 402 | A plan quota was reached (documents, seats, storage). Upgrade to continue. |
| FORBIDDEN | 403 | Authenticated, but not allowed to perform this action. |
| SCOPE_REQUIRED | 403 | The API key lacks the scope required by this endpoint. |
| MODULE_DISABLED | 403 | The feature module used by this endpoint is not enabled for the organization. |
| NOT_FOUND | 404 | The resource does not exist or belongs to another organization. |
| CONTACT_NOT_FOUND | 404 | The `contact_id` or `contact_external_id` on a document does not resolve to a contact in this organization. Create the contact first or check the external reference. |
| CONFLICT | 409 | The request conflicts with current state (duplicate, stale version). |
| IDEMPOTENCY_KEY_REUSED | 409 | This Idempotency-Key was already used with different request parameters. Generate a fresh key for a different request — reusing one is how a retry silently returns the previous call’s result. |
| EXTERNAL_ID_TAKEN | 409 | Another contact or article in this organization already carries this `external_id`. External ids are unique per organization; look the existing row up with `?external_id=` and update it instead. |
| IDEMPOTENCY_IN_PROGRESS | 409 | Another request with this Idempotency-Key is still executing. Retry after a short delay to receive the stored response. |
| VALIDATION_ERROR | 422 | Input failed validation. `error.details` maps field → messages. |
| EXCHANGE_RATE_UNAVAILABLE | 422 | A non-RON document needs the BNR T-1 reference rate (Cod Fiscal art. 290) and it could not be obtained for that currency and issue date. Retry later or pass a positive `exchange_rate` explicitly; the rate is never defaulted to 1. |
| INVOICE_CREDITED | 422 | The invoice is fully reversed by credit notes (status CREDITED). Its status and balance are derived from the credit notes: it cannot take payments and its status cannot be set by hand. |
| INVOICE_NOT_DELETABLE | 409 | Only a DRAFT invoice that was never submitted to e-Factura can be deleted. An issued invoice is a numbered fiscal document: reverse it with a credit note (POST /v1/invoices/{id}/credit-note). |
| INVOICE_NOT_EDITABLE | 409 | The change is not allowed on this invoice. PATCH may move an issued invoice only between its chase states (SENT → VIEWED/OVERDUE) or cancel a draft; issue with /send, record payments with /payments, reverse with a credit note. Fiscal fields (contact, dates, currency, items) are frozen once the invoice is issued or known to e-Factura. |
| SOURCE_FULLY_CREDITED | 422 | The source invoice is already fully credited (status CREDITED); no further credit note can reverse it. |
| CREDIT_EXCEEDS_SOURCE | 422 | The credit note would take the cumulative credited amount above the source invoice total. A full storno is possible only once; partial credit notes may add up to the total and not beyond. |
| from_domain_not_verified | 422 | POST /v1/email/send: the `from` address is not on a sending domain this organization has verified. Verify it via /v1/email-domains (DKIM, SPF, DMARC) or send from a verified domain. |
| sender_identity_missing | 422 | POST /v1/email/send and /v1/emails/send: the `from` domain is verified but no sending identity (the mail-server account the message authenticates as) may send as that exact address, so the message would be refused. A `from` is sendable when it is the domain's sending address (`<from_local_part>@<domain>`) or a hosted mailbox of the organization, or one of its aliases, with API sending enabled (Mail settings). Nothing was recorded or sent. Re-run /v1/email-domains/{id}/verify if the domain identity is missing, or omit `from` to send as the organization default. |
| RATE_LIMITED | 429 | Per-key rate limit exceeded. Honor the `Retry-After` header. |
| INTERNAL_ERROR | 500 | Unexpected server error. Safe to retry with backoff. |
| send_failed | 502 | POST /v1/email/send: the mail transport refused the message and it could not be queued for retry. Nothing was delivered. A replay with the same Idempotency-Key returns this same error; retry with a new key. |
| SERVICE_UNAVAILABLE | 503 | A dependent service (VIES, DNS, registrar) is unavailable or not configured. |
brivio_sk_test_… keys authenticate against a paired sandbox organization, provisioned automatically on first use. Data is fully isolated from your live organization, fiscal identifiers stay empty, and no ANAF/e-Factura submissions or other external side effects ever fire. Webhooks are environment- scoped too: endpoints registered with a test key only receive test events.
Deliveries follow Standard Webhooks: each request carries webhook-id, webhook-timestamp (unix seconds) and webhook-signature, a space-separated list of v1,<base64> tokens — HMAC-SHA256 over {id}.{timestamp}.{rawBody}, one token per active secret while a secret is being rotated. The timestamp is inside the MAC, so a captured delivery cannot be replayed with a new timestamp:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyBrivioWebhook(
rawBody: string,
id: string, // "webhook-id" header
timestamp: string, // "webhook-timestamp" header, unix seconds
signature: string, // "webhook-signature" header, "v1,<base64> [v1,<base64>]"
secret: string, // "whsec_..."
): boolean {
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false; // 5 min window
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(
"v1," + createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`, "utf8").digest("base64"),
);
return signature.split(" ").some((token) => {
const actual = Buffer.from(token);
return actual.length === expected.length && timingSafeEqual(actual, expected);
});
}Or use verifyWebhookSignature() from the @brivio-ro/sdk package, which implements the same check.