Jupiter ERP

Webhooks

Jupiter ERP sends business events as an HTTPS POST to an address you provide. This page describes the message, the signature, and when an event is created.

POST · JSONAt-least-onceAPI 1.0.0Account API

What arrives

Method
POST
Content-Type
application/json
Time limit
10 seconds
Redirects
are not followed
User-Agent
Jupiter-ERP-Webhook/1.0
Signature
Jupiter-ERP-Signature

Jupiter ERP calls public HTTPS addresses only. A business event is stored only when at least one active endpoint is subscribed to that type. The same event id can go to several endpoints. Each endpoint has its own delivery.

webhook.ping starts only from Send test on that endpoint. The event selection does not matter for this. An inactive endpoint does not accept a test.

You add endpoints in the panel under System → Webhooks. The feature is included in the trial and on the Enterprise plan, with up to five endpoints. Jupiter ERP sends headers you add, for example an API key, with every delivery. Jupiter ERP sets Content-Type, User-Agent, and Jupiter-ERP-Signature itself.

Verify the signature

Check every delivery before you accept the contents. The signature covers the timestamp and the raw body. No other header is part of it.

  1. 1

    Read the raw body

    Read the body before a framework parses it as JSON. The text is UTF-8. The signature applies to those exact bytes. Reformatting changes spaces, field order, or slashes, and then the signature no longer matches.

  2. 2

    Split the header

    The header name is Jupiter-ERP-Signature. Its value looks like t=1710000000,v1=hex. Split the parts on commas, then each part on the first equals sign. t is Unix time in seconds. v1 is the signature as lowercase hex.

  3. 3

    Check the timestamp

    Compare t with your clock and reject the call when the gap is larger than five minutes. That limit is yours. Jupiter ERP does not set one. Each delivery attempt uses a new t and therefore a new signature. The body stays the same. Use the id, which starts with evt_, to decide whether you already handled the fact.

  4. 4

    Compute the HMAC

    The signed text is t, then a dot, then the raw body, with nothing between them. The key is this endpoint's signing secret. The algorithm is HMAC-SHA256. The result is lowercase hex.

  5. 5

    Compare

    Compare v1 with the computed value in constant time. That is hash_equals in PHP, timingSafeEqual in Node, and hmac.compare_digest in Python. If v1 is missing or different, discard the call. The secret is shown only when you create or rotate the endpoint.

Check values

Compare your verifier against these fixed bytes. The secret is an example and does not belong to an endpoint.

Secret
whsec_test_secret
t
1710000000
Raw body
{"id":"evt_01EXAMPLE","type":"webhook.ping","api_version":"1.0.0","created_at":"2026-10-10T19:00:00+00:00","account_uuid":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","data":{"object":"ping","item":{"ok":true}}}
Signed text
1710000000.{"id":"evt_01EXAMPLE","type":"webhook.ping","api_version":"1.0.0","created_at":"2026-10-10T19:00:00+00:00","account_uuid":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","data":{"object":"ping","item":{"ok":true}}}
Header
t=1710000000,v1=951aa3172e030732bea285376242e0a047079aca0031abc2246f4539d0f74bba
Verification
import { createHmac, timingSafeEqual } from "node:crypto";

const MAX_SKEW_SECONDS = 5 * 60;

export function verifyJupiterSignature(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((piece) => {
      const index = piece.indexOf("=");
      return [piece.slice(0, index), piece.slice(index + 1)];
    }),
  );
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp)) {
    return false;
  }
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > MAX_SKEW_SECONDS) {
    return false;
  }

  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const actual = parts.v1 ?? "";
  if (expected.length !== actual.length) {
    return false;
  }
  return timingSafeEqual(Buffer.from(expected), Buffer.from(actual));
}

Response and retries

2xx

The delivery is finished. Answer within 10 seconds and do the work after that.

410

The endpoint is turned off immediately. That does not count toward the ten failures.

429, 5xx, timeout, network

Retry after about 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, and 12 hours. The gaps vary slightly. After the seventh attempt the delivery is failed.

Other 4xx and redirects

No retry. Redirects are not followed.

Ten failures in a row

The endpoint is turned off. A successful delivery resets the counter.

Envelope

Retries send the same body, but a new timestamp in the signature. Amounts are in cents: 11900 is 119.00. Send account_uuid as the X-Account-Uuid header when you load the full record from the Account API. The id starts with evt_. The same id is the same fact.

invoice.sent
{
  "id": "evt_01J7ABCDEFGHJKMNPQRSTVWXYZ",
  "type": "invoice.sent",
  "api_version": "1.0.0",
  "created_at": "2026-10-10T19:00:00+00:00",
  "account_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "data": {
    "object": "invoice",
    "item": {
      "id": 456,
      "status": "open",
      "number": 10001,
      "formatted_number": "RE-10001",
      "date": "2026-10-10",
      "sum_net": 10000,
      "sum_gross": 11900,
      "currency_code": "EUR",
      "contact": { "id": 12, "name": "Muster GmbH" },
      "project": null,
      "cost_center": null
    }
  }
}

Events

data.item is a short snapshot from the moment the event was created. Read the full record through the Account API.

Downloads

The OpenAPI 3.1 document lists the events under webhooks. There are no paths: Jupiter ERP does not receive these calls.

Webhooks: reference · Jupiter ERP