Send Invoice — TypeScript Examples
No SDK required. These examples use native fetch (Node.js 18+ or any modern runtime).
Setup
const API_BASE = "https://api.sandbox.invostaq.com/api"; // swap for api.invostaq.com in production
const API_KEY = process.env.INVOSTAQ_API_KEY!; // sk_test_... or sk_live_...
async function invostaq<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
"x-api-key": API_KEY,
"Content-Type": "application/json",
...options.headers,
},
});
if (!res.ok) {
const problem = await res.json();
throw Object.assign(new Error(`${problem.title}: ${problem.detail}`), { problem });
}
return res.json();
}
1. Look up a recipient
const lookup = await invostaq<{
participantId: string;
isRegistered: boolean;
accessPointRef: string;
supportedDocTypes: string[];
}>("/participants/lookup?participantId=0196:971501234567");
if (!lookup.isRegistered) {
throw new Error("Recipient is not registered on the Peppol network");
}
console.log("Access point:", lookup.accessPointRef);
2. Send an invoice
const result = await invostaq<{
invoiceId: string;
transactionId: string;
status: string;
networkStatus: string;
idempotencyKeyEcho: string;
}>("/invoices/send", {
method: "POST",
headers: { "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({
invoice: {
number: "INV-2024-001",
issueDate: "2024-06-15",
dueDate: "2024-07-15",
currency: "EUR",
supplier: {
name: "Acme Trading NV",
taxId: "BE0417497106",
address: { line1: "Rue de la Loi 1", city: "Brussels", postalCode: "1000", countryCode: "BE" },
},
buyer: {
name: "Gulf Imports Co",
taxId: "971501234567",
address: { line1: "Sheikh Zayed Road", city: "Dubai", countryCode: "AE" },
},
lineItems: [
{
description: "Consulting services — June 2024",
quantity: 1,
unitPrice: 1000.0,
taxRate: 21.0, // percentage, not decimal
},
],
totals: {
subtotal: 1000.0,
taxAmount: 210.0,
grandTotal: 1210.0,
},
},
routing: {
senderParticipantId: "0208:0417497106",
receiverParticipantId: "0196:971501234567",
accessPointRef: lookup.accessPointRef,
},
}),
});
console.log(`Sent! Invoice: ${result.invoiceId}, Transaction: ${result.transactionId}`);
3. End-to-end: lookup + send
interface InvoiceLine {
description: string;
quantity: number;
unitPrice: number;
taxRate: number; // percentage, e.g. 21.0 for 21%
productCode?: string;
}
interface InvoiceInput {
number: string;
issueDate: string; // YYYY-MM-DD
dueDate?: string;
currency: string; // ISO 4217, e.g. "EUR"
supplier: { name: string; taxId: string; address: object };
buyer: { name: string; taxId?: string; address: object };
lineItems: InvoiceLine[];
totals: { subtotal: number; taxAmount: number; grandTotal: number };
}
async function sendInvoice(
senderParticipantId: string,
receiverParticipantId: string,
invoice: InvoiceInput,
idempotencyKey: string
) {
// Step 1: Verify the recipient is reachable
const lookup = await invostaq<{ isRegistered: boolean; accessPointRef: string }>(
`/participants/lookup?participantId=${encodeURIComponent(receiverParticipantId)}`
);
if (!lookup.isRegistered) {
throw new Error(`Recipient ${receiverParticipantId} is not registered on the Peppol network`);
}
// Step 2: Send
return invostaq<{ invoiceId: string; transactionId: string; networkStatus: string }>(
"/invoices/send",
{
method: "POST",
headers: { "Idempotency-Key": idempotencyKey },
body: JSON.stringify({
invoice,
routing: {
senderParticipantId,
receiverParticipantId,
accessPointRef: lookup.accessPointRef,
},
}),
}
);
}
// Usage
const result = await sendInvoice(
"0208:0417497106",
"0196:971501234567",
{
number: "INV-2024-001",
issueDate: "2024-06-15",
dueDate: "2024-07-15",
currency: "EUR",
supplier: {
name: "Acme Trading NV",
taxId: "BE0417497106",
address: { line1: "Rue de la Loi 1", city: "Brussels", postalCode: "1000", countryCode: "BE" },
},
buyer: {
name: "Gulf Imports Co",
address: { city: "Dubai", countryCode: "AE" },
},
lineItems: [{ description: "Consulting services", quantity: 1, unitPrice: 1000.0, taxRate: 21.0 }],
totals: { subtotal: 1000.0, taxAmount: 210.0, grandTotal: 1210.0 },
},
"inv-2024-001-attempt-1"
);
console.log(`Invoice: ${result.invoiceId} — ${result.networkStatus}`);
4. Error handling
async function sendWithErrorHandling(payload: object, idempotencyKey: string) {
const res = await fetch(`${API_BASE}/invoices/send`, {
method: "POST",
headers: {
"x-api-key": API_KEY,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
if (res.ok) return res.json();
const problem = await res.json();
switch (problem.type) {
case "https://invostaq.com/errors/declared-totals-mismatch":
throw new Error(`Totals don't reconcile: ${problem.detail}`);
case "https://invostaq.com/errors/peppol-pre-validation-failed":
throw new Error(`Peppol validation:\n${(problem.validationErrors as string[]).join("\n")}`);
case "https://invostaq.com/errors/network-failed":
throw new Error(`Peppol network error ${problem.errorCode} (HTTP ${problem.upstreamStatus})`);
default:
throw new Error(`${problem.title}: ${problem.detail}`);
}
}
5. Production client with retry
Handles rate limiting (429) and transient server errors (5xx) automatically.
async function invostaqWithRetry<T = unknown>(
path: string,
options: RequestInit = {},
maxAttempts = 3
): Promise<T> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
"x-api-key": API_KEY,
"Content-Type": "application/json",
...options.headers,
},
});
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get("Retry-After") ?? "60", 10);
console.log(`Rate limited. Waiting ${retryAfter}s...`);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
continue;
}
if (res.status >= 500 && attempt < maxAttempts - 1) {
const delay = 2 ** attempt * 1000;
console.log(`Server error ${res.status}. Retrying in ${delay}ms...`);
await new Promise((r) => setTimeout(r, delay));
continue;
}
if (!res.ok) {
const problem = await res.json();
throw Object.assign(new Error(`${problem.title}: ${problem.detail}`), { problem });
}
return res.json();
}
throw new Error("Max retries exceeded");
}
// Usage — drop-in for invostaq()
const result = await invostaqWithRetry<{ invoiceId: string }>("/invoices/send", {
method: "POST",
headers: { "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify(payload),
});
| Response | Action |
|---|---|
200 | Return JSON |
429 | Wait Retry-After seconds, retry |
5xx | Exponential backoff (1s, 2s, 4s), retry |
4xx | Throw immediately — the request needs fixing |
| All attempts exhausted | Throw "Max retries exceeded" |
The Idempotency-Key makes all retries safe — if the first attempt succeeded but you didn't receive the response, the retry returns the original result without resubmitting to Peppol.