Skip to content

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

  1. Connect the store once. You get a webhook URL and a signing secret.
  2. Whenever an order is placed or changes, send its full current state to Space Invoices.
  3. Space Invoices stores the order and applies the connection’s automation: it issues a document, sends the email, fiscalizes and records the payment.
  4. You follow the results on the Orders page, through webhooks such as order.created, order.processed and order.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:

Connect a custom storetypescript
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:

SettingDefaultWhat it means
Automatic processingOnOrders create documents without manual review.
Process onOrder createdDocuments 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 emailOnThe customer gets the invoice by email. It stays off in countries without document email.
Attach PDF to emailOffThe email links to the document; turn this on to attach the PDF.
Issue invoices immediately for bank transfersOffUnpaid bank transfers get an estimate first.
Wait for completion before issuing documentsOffTurn this on to issue documents only after the order is fulfilled.
Fiscalization premise and deviceFirst active pairSlovenian 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.

Send a signed order webhookjavascript
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");
Sign and send a webhook from PHPphp
// 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}");
}
Sign and send a webhook from the shellbash
# 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:

Push an order with the SDKtypescript
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);
Push an order over RESTbash
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 happenedWhat to sendWhat Space Invoices does
Order placed and paid onlinepaid: true, payment_method: "card"Issues the invoice, records the payment and sends the email.
Order placed, online payment still pendingpaid omitted, payment_method: "card"Stores the order without issuing anything. The invoice follows when you send paid: true.
Order placed, bank transfer pendingpaid omitted, payment_method: "bank_transfer"Issues an estimate, unless invoices are set to be issued immediately for bank transfers.
Bank transfer arrivedthe same order with paid: trueConverts the estimate into a paid invoice, or records the payment on an existing invoice.
Order shippedfulfilled: trueWith 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 invoicingthe updated linesWith reissue on order changes, the old invoice is credited and a new one issued. Otherwise the order is marked as changed for review.
Order cancelledcancelled: trueEnds automation. An order without a document never gets one. An issued invoice stays valid; send a refund to reverse it.
Order fully refundedrefunded: trueVoids the issued invoice with a credit note, including fiscalization. An estimate is voided.
Send a status update when the order is paidtypescript
// 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",
});
Send a full refundtypescript
// 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:paid and 1042:refunded. Send it as event_id in the body or as the X-SI-Event-Id header; 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 a 5xx, 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 200 and {"skipped": true} without processing. An API push returns 409. After you re-enable the store, send the orders you want processed again.
StatusMeaning
200Accepted. The response says whether the order was created, updated or ignored.
401Webhook only: the signature is missing, invalid, or more than five minutes old.
404The integration doesn’t exist, or doesn’t belong to the entity.
409API push only: the store is disabled.
422The order doesn’t match the format. The message lists the fields to fix.

Order Format

FieldNotes
idYour order ID. It is unique within this store and repeated on every update.
order_numberThe number your buyer sees, for example #1042.
event_idOptional stable ID of this logical event (e.g. 1042:paid), the same across retries. It wins over the X-SI-Event-Id header.
currency_codeUppercase ISO 4217 code, for example EUR.
prices_include_taxWhether unit_price and discount include tax. Defaults to false.
total_with_taxWhat 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.
items1–500 lines. kind is line_item (the default), shipping or fee.
items[].unit_pricePrice per unit, on the tax basis set by prices_include_tax.
items[].discountDiscount amount for the whole line, on the same basis.
items[].tax_rateTax percentage. Send 0 for an explicit zero rate, and leave it out when the line has no tax.
items[].skuLinks the line to the catalog item with the same SKU. An unknown SKU creates that item once.
customername, email, phone, company_name, tax_number, company_number, is_business, notes. Tax and company numbers are kept as text.
billing_address, shipping_addressaddress, address_2, city, state, post_code, country, country_code.
localeBuyer or checkout language. It sets the document language before the billing country does.
payment_methodcard, 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_referenceYour payment provider’s transaction ID, stored with the recorded payment.
paid, fulfilled, cancelled, refundedCurrent lifecycle flags. Each one can have a matching *_at timestamp, which requires the flag to be true.
ordered_atWhen 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.failed and order.cancelled to 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.