Custom Store Invoicing
Use a custom store connection when your shop is built in-house or runs on a platform without a native connector. Your store sends its orders to Space Invoices, and they go through the same order flow as Shopify and WooCommerce orders: automatic invoices or estimates, invoice emails, fiscalization, payment recording and refund handling.
You don’t call the invoice endpoints yourself. You send what happened to the order, and the connection’s settings decide which documents to create. If you would rather create every document from your backend, see E-Commerce.
How It Works
- Connect the store once. You get a webhook URL and a signing secret.
- Whenever an order is placed or changes, send its full current state to Space Invoices.
- Space Invoices stores the order and applies the connection’s automation: it issues a document, sends the email, fiscalizes and records the payment.
- You follow the results on the Orders page, through webhooks such as
order.created,order.processedandorder.failed, or with the Orders API.
Connect Your Store
Only an entity admin can connect a store. In the app, open Integrations, choose Custom store and select Connect custom store. Alternatively, connect the store through the API:
const connection = await sdk.orderIntegrations.connectCustomStore({
name: "My online store",
store_url: "https://shop.example.com",
auto_process_on: "created",
});
// Save both values in your store's configuration. The secret is shown only once.
console.log("Webhook URL:", connection.webhook_url);
console.log("Signing secret:", connection.signing_secret);The response contains:
webhook_url: where your store sends orders.signing_secret: signs every webhook. It is shown only once, so store it like a password.
To replace a secret, call POST /order-integrations/{id}/custom/rotate-secret or select Rotate signing secret in the store’s details. The previous secret keeps working for 24 hours. During that time you can sign with either secret, or with both.
Try the connection in a sandbox entity first. Orders sent there create sandbox documents only.
Default Settings
Every new store connection is ready to use. This applies to Shopify, WooCommerce and custom stores, and to integrations created with POST /order-integrations:
| Setting | Default | What it means |
|---|---|---|
| Automatic processing | On | Orders create documents without manual review. |
| Process on | Order created | Documents are created when the order arrives. For custom stores and WooCommerce, an online payment waits until the order is paid. Unpaid bank transfers start as estimates. You can switch to Paid (wait for payment) or Fulfilled (wait for fulfilled: true). |
| Send invoice email | On | The customer gets the invoice by email. It stays off in countries without document email. |
| Attach PDF to email | Off | The email links to the document; turn this on to attach the PDF. |
| Issue invoices immediately for bank transfers | Off | Unpaid bank transfers get an estimate first. |
| Wait for completion before issuing documents | Off | Turn this on to issue documents only after the order is fulfilled. |
| Fiscalization premise and device | First active pair | Slovenian and Croatian entities use their first active business premise and electronic device. |
Change any setting under Edit integration, or with PATCH /order-integrations/{id}. To review orders by hand instead, turn off Auto Process, or pass auto_process: false when you create the integration.
Send a New Order
Send the order as soon as it is placed. Use the same order id for the whole life of the order. That ID is unique within this store.
Always send an event ID. Give every logical event its own stable event_id, for example 1043:created, 1043:paid or 1043:refunded, and reuse it whenever you retry that event. Without an event ID, a retried earlier send can’t be recognized: it is applied again and can overwrite newer state, for example turning a paid order back into an unpaid one.
Signed webhooks
Post the order JSON to your webhook_url and sign it:
X-SI-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">X-SI-Event-Id: <id of this logical event> (optional; event_id in the body wins)The signature covers the timestamp, a dot, and the exact bytes you send. Requests whose timestamp is more than five minutes from the current time are rejected, so sign each request when you send it.
import crypto from "node:crypto";
// eventId names the logical event, e.g. __PROTECTED_4__ or __PROTECTED_5__. Keep it the same when you
// retry the delivery; only the timestamp and signature change on a retry.
async function sendOrder(webhookUrl, signingSecret, storeOrder, eventId) {
const body = JSON.stringify({ ...storeOrder, event_id: eventId });
const timestamp = Math.floor(Date.now() / 1000);
const signature = crypto.createHmac("sha256", signingSecret).update(`${timestamp}.${body}`).digest("hex");
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-SI-Signature": `t=${timestamp},v1=${signature}`,
},
body,
});
if (!response.ok) throw new Error(`Order push failed: ${response.status} ${await response.text()}`);
}
await sendOrder("https://eu.spaceinvoices.com/v1/webhooks/orders/oint_123", "whsec_...", {
id: "1042",
order_number: "#1042",
currency_code: "EUR",
prices_include_tax: true,
total_with_tax: 61,
customer: { name: "Jane Doe", email: "jane@example.com" },
billing_address: { address: "Slovenska cesta 1", city: "Ljubljana", post_code: "1000", country_code: "SI" },
items: [
{ name: "Organic coffee beans 1kg", sku: "COF-1KG", quantity: 2, unit_price: 24.4, tax_rate: 22 },
{ kind: "shipping", name: "Express delivery", quantity: 1, unit_price: 12.2, tax_rate: 22 },
],
payment_method: "card",
paid: true,
paid_at: "2026-10-01T09:31:00Z",
}, "1042:paid");// Name the logical event and keep the same ID when you retry it, e.g. "1042:created" or "1042:paid".
$eventId = '1042:paid';
// Assign it explicitly: it must replace any event_id already in $order (including null).
$order['event_id'] = $eventId;
$body = json_encode($order);
$timestamp = time();
$signature = hash_hmac('sha256', $timestamp . '.' . $body, $signingSecret);
$ch = curl_init($webhookUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"X-SI-Signature: t={$timestamp},v1={$signature}",
],
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($status >= 300) {
throw new RuntimeException("Order push failed: {$status} {$response}");
}# Name the logical event and keep the same ID when you retry it, e.g. "1042:created" or "1042:paid".
EVENT_ID="1042:created"
# It is written into the body because a body event_id wins over the X-SI-Event-Id header.
BODY=$(jq -c --arg event_id "$EVENT_ID" '.event_id = $event_id' order.json)
TIMESTAMP=$(date +%s)
SIGNATURE=$(printf '%s.%s' "$TIMESTAMP" "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -hex | sed 's/^.* //')
curl -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-H "X-SI-Signature: t=$TIMESTAMP,v1=$SIGNATURE" \
--data-binary "$BODY"API push
A server integration can send the same order JSON with an API key instead of a signature:
const result = await sdk.orderIntegrations.pushCustomStoreOrder("oint_123", {
id: "1042",
// A stable ID per logical event, reused if you retry this push.
event_id: "1042:created",
order_number: "#1042",
currency_code: "EUR",
prices_include_tax: true,
total_with_tax: 61,
customer: { name: "Jane Doe", email: "jane@example.com" },
items: [
{ name: "Organic coffee beans 1kg", sku: "COF-1KG", quantity: 2, unit_price: 24.4, tax_rate: 22 },
{ kind: "shipping", name: "Express delivery", quantity: 1, unit_price: 12.2, tax_rate: 22 },
],
payment_method: "card",
paid: true,
});
console.log(result.action, result.order_id, result.processing_queued);curl -X POST https://eu.spaceinvoices.com/order-integrations/oint_123/orders \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "x-entity-id: ent_123" \
-H "Content-Type: application/json" \
-d '{
"id": "1043",
"event_id": "1043:created",
"currency_code": "EUR",
"total_with_tax": 50,
"items": [{ "name": "Gift card", "quantity": 1, "unit_price": 50, "tax_rate": 0 }],
"payment_method": "bank_transfer"
}'The response is { "order_id", "action", "processing_queued" }. action is created for a new order, updated for a known one, and ignored when nothing changed, for example after a repeated event_id.
Send Status Updates
Every send is a full snapshot. When anything changes, send the whole order again, with the same id, the same lines and totals, and every lifecycle flag that is currently true. A flag you leave out (paid, fulfilled, cancelled, refunded) is stored as false.
| What happened | What to send | What Space Invoices does |
|---|---|---|
| Order placed and paid online | paid: true, payment_method: "card" | Issues the invoice, records the payment and sends the email. |
| Order placed, online payment still pending | paid omitted, payment_method: "card" | Stores the order without issuing anything. The invoice follows when you send paid: true. |
| Order placed, bank transfer pending | paid omitted, payment_method: "bank_transfer" | Issues an estimate, unless invoices are set to be issued immediately for bank transfers. |
| Bank transfer arrived | the same order with paid: true | Converts the estimate into a paid invoice, or records the payment on an existing invoice. |
| Order shipped | fulfilled: true | With Wait for completion before issuing documents, or with processing set to Fulfilled, the document is issued now. Otherwise the order is just updated. |
| Lines or totals changed after invoicing | the updated lines | With reissue on order changes, the old invoice is credited and a new one issued. Otherwise the order is marked as changed for review. |
| Order cancelled | cancelled: true | Ends automation. An order without a document never gets one. An issued invoice stays valid; send a refund to reverse it. |
| Order fully refunded | refunded: true | Voids the issued invoice with a credit note, including fiscalization. An estimate is voided. |
// The bank transfer arrived: send the same order again, now with paid: true.
// An estimate created for the unpaid order is converted into a paid invoice.
await sdk.orderIntegrations.pushCustomStoreOrder("oint_123", {
id: "1043",
event_id: "1043:paid",
currency_code: "EUR",
total_with_tax: 50,
items: [{ name: "Gift card", quantity: 1, unit_price: 50, tax_rate: 0 }],
payment_method: "bank_transfer",
payment_reference: "SI56 0201 0001 2345 678",
paid: true,
paid_at: "2026-10-03T08:15:00Z",
});// A full refund voids the issued invoice with a credit note.
await sdk.orderIntegrations.pushCustomStoreOrder("oint_123", {
id: "1042",
event_id: "1042:refunded",
currency_code: "EUR",
prices_include_tax: true,
total_with_tax: 61,
items: [
{ name: "Organic coffee beans 1kg", sku: "COF-1KG", quantity: 2, unit_price: 24.4, tax_rate: 22 },
{ kind: "shipping", name: "Express delivery", quantity: 1, unit_price: 12.2, tax_rate: 22 },
],
payment_method: "card",
paid: true,
fulfilled: true,
refunded: true,
refunded_at: "2026-10-05T10:00:00Z",
});Some rules always apply:
- Cancelled and refunded are final. Later sends can’t reopen the order or trigger new documents, payments or emails.
- An order that arrives already cancelled or refunded is stored as cancelled and never creates a document.
- Partial refunds aren’t automated. Create the credit note yourself through the credit notes API, or in the app.
- Issued documents don’t change. Later sends update the order, but an invoice is never recalculated from newer order data.
Deliveries, Retries and Order
- Retries are safe. Give every logical event of an order its own stable ID, for example
1042:created,1042:paidand1042:refunded. Send it asevent_idin the body or as theX-SI-Event-Idheader; when both are present, the body wins. Keep the same ID when you retry that event. Don’t derive it from the send time: two events sent in the same second would then share an ID. Every applied ID is remembered per order, so a repeated ID is acknowledged and ignored even after later events. A delivery that failed with an error isn’t recorded and can be retried with the same ID. If you get no response or a5xx, send the same event again with the same ID and a fresh signature. - Send changes in order. Each send replaces the stored state of the order. If an older snapshot arrives after a newer one, send the latest state again. Payments and documents that were already created are not undone.
- Disabled stores answer webhooks with
200and{"skipped": true}without processing. An API push returns409. After you re-enable the store, send the orders you want processed again.
| Status | Meaning |
|---|---|
200 | Accepted. The response says whether the order was created, updated or ignored. |
401 | Webhook only: the signature is missing, invalid, or more than five minutes old. |
404 | The integration doesn’t exist, or doesn’t belong to the entity. |
409 | API push only: the store is disabled. |
422 | The order doesn’t match the format. The message lists the fields to fix. |
Order Format
| Field | Notes |
|---|---|
id | Your order ID. It is unique within this store and repeated on every update. |
order_number | The number your buyer sees, for example #1042. |
event_id | Optional stable ID of this logical event (e.g. 1042:paid), the same across retries. It wins over the X-SI-Event-Id header. |
currency_code | Uppercase ISO 4217 code, for example EUR. |
prices_include_tax | Whether unit_price and discount include tax. Defaults to false. |
total_with_tax | What the buyer pays. If the lines don’t add up to it within rounding (0.02, or half a cent per line on larger orders), the order is stored as Failed with an explanation instead of being invoiced. Sending the corrected order processes it automatically. |
items | 1–500 lines. kind is line_item (the default), shipping or fee. |
items[].unit_price | Price per unit, on the tax basis set by prices_include_tax. |
items[].discount | Discount amount for the whole line, on the same basis. |
items[].tax_rate | Tax percentage. Send 0 for an explicit zero rate, and leave it out when the line has no tax. |
items[].sku | Links the line to the catalog item with the same SKU. An unknown SKU creates that item once. |
customer | name, email, phone, company_name, tax_number, company_number, is_business, notes. Tax and company numbers are kept as text. |
billing_address, shipping_address | address, address_2, city, state, post_code, country, country_code. |
locale | Buyer or checkout language. It sets the document language before the billing country does. |
payment_method | card, paypal, bank_transfer, cash (cash on delivery) or coupon. Common spellings such as Bank transfer, SEPA, cod or cash_on_delivery are recognized. Other values count as online payments. |
payment_reference | Your payment provider’s transaction ID, stored with the recorded payment. |
paid, fulfilled, cancelled, refunded | Current lifecycle flags. Each one can have a matching *_at timestamp, which requires the flag to be true. |
ordered_at | When the order was placed. |
Monitor and Recover
- Orders page: the store’s details have View imported orders. That list shows each order’s status, its document and the reason when processing failed.
- Failed orders keep their error, for example totals that don’t reconcile or a missing fiscal setting. To retry, fix the cause and send the corrected order again, or select Reprocess, or call
POST /orders/{id}/process. Retries reuse documents that were already saved, so nothing is issued twice. - Webhooks to your systems: subscribe to
order.created,order.processed,order.failedandorder.cancelledto react to the outcome.
Billing
Each active custom store counts as one connected store in your subscription, like a Shopify or WooCommerce store. Disabled and deleted stores don’t count.
Related Guides and API
- Order Integrations API: connection, secret rotation and order push
- Orders API: orders, processing and recovery
- Webhooks: order events for your own systems
- WooCommerce Invoicing and Shopify Invoicing: the native connectors
- E-Commerce: choosing an order-to-invoice approach