Developers

egful API

Push orders into our factory and get production status and tracking back. REST over HTTPS, JSON in and out, API-key auth, signed webhooks.

Authentication

Every request carries an API key in the X-API-Key header. Generate one in your dashboard under Settings → API keys.

curl https://api.egful.store/api/v1/ping \
  -H "X-API-Key: egk_test_..."

Keys come in two modes, and the prefix tells you which you are holding.

  • egk_test_… — the sandbox. Nothing is produced and nothing is billed, but pricing and validation are identical to live, so an order that succeeds here succeeds there. Webhooks still fire, flagged test: true.
  • egk_live_… — real. Orders enter the factory queue and are billed.

The full key is shown once, at creation, and never again. We store only a hash.

Scopes

A key can be limited to what an integration actually needs. Grant the narrowest set that works — this is a credential you are handing to someone else.

ScopeAllows
orders.writeCreate orders.
orders.readRead an order's status and tracking.
products.readList the blanks you can order.
webhooks.readList endpoints and their delivery history.
webhooks.writeAdd, remove and test endpoints.
billing.readRead your balance and statements.

A call outside a key's scopes returns 403 with insufficient_scopeand names what was required. A key created without any scopes has full access, which keeps older integrations working — but new keys should name theirs.

Rate limits

ScopeLimit
All endpoints600 requests / minute / key
Order creation60 requests / minute / key

Every response carries X-RateLimit-Limit, X-RateLimit-Remaining andX-RateLimit-Reset (seconds until the window rolls) — so you can slow down before you are cut off rather than after. Exceeding a limit returns 429 withRetry-After.

Endpoints

Base URL https://api.egful.store. All paths take and return application/json.

GET/api/v1/pingValidate key

Checks your test key and confirms the sandbox is reachable.

Try it
curl -X GET https://api.egful.store/api/v1/ping \
  -H "X-API-Key: egk_test_..."
Response
{
  "ok": true,
  "mode": "test",
  "live": false,
  "seller_id": "8f3c…",
  "time": "2026-07-21T16:04:11.882Z",
  "message": "Sandbox reachable — your test key is valid."
}
GET/api/v1/productsList products

The blanks you can print on — your real catalogue, in both test and live mode. Order lines must match a sku from here or the order is refused.

Try it
curl -X GET https://api.egful.store/api/v1/products \
  -H "X-API-Key: egk_test_..."
Response
{
  "object": "list",
  "mode": "test",
  "count": 2,
  "data": [
    { "id": "SS-16468", "sku": "16468", "name": "Heavy Cotton Tee",
      "type": "Apparel", "method": "DTG", "base_price": 8.50 }
  ]
}
POST/api/v1/ordersCreate order

Create a fulfillment order. Needs an items array and a shipping_address. A test key validates and prices identically to live but produces nothing; a live key puts it in the factory queue. Every product_id must exist in GET /api/v1/products — we refuse an order we cannot price rather than inventing a number. `totals` covers the LINE ITEMS only: postage is quoted when the order goes to production, not when it is created, so it is not part of this response in either mode. Send the same `external_id` twice and you get the first order back with `idempotent: true` rather than a duplicate.

Try it
curl -X POST https://api.egful.store/api/v1/orders \
  -H "X-API-Key: egk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
  "external_id": "my-store-1001",
  "items": [
    {
      "product_id": "16468",
      "quantity": 2,
      "color": "Black",
      "size": "L",
      "method": "DTG"
    }
  ],
  "shipping_address": {
    "name": "Ava Brodeur",
    "street1": "43 Calumet Rd",
    "city": "Fairhaven",
    "state": "MA",
    "zip": "02719",
    "country": "US"
  }
}'
Response
{
  "object": "order",
  "mode": "live",
  "id": "API-9F2C1A",
  "status": "received",
  "items": [
    { "line": 1, "sku": "16468", "quantity": 2, "size": "L",
      "unit_price": 8.50, "line_total": 17.00 }
  ],
  "shipping_address": { "name": "Ava Brodeur", "street1": "43 Calumet Rd",
    "city": "Fairhaven", "state": "MA", "zip": "02719", "country": "US" },
  "totals": { "items": 17.00, "currency": "USD" },
  "created": "2026-07-21T16:04:11.882Z"
}
POST/api/v1/orders/quoteQuote an order

What a basket costs, before there is an order. Same items array as Create order; the shipping address is not needed. It runs the SAME pricing the charge runs — the per-size cost ladder, the print-method surcharge, the dearest line setting postage, the extra-item rate and your own discount — so the figure here is the figure you are billed. Shipping is our fulfilment charge for the basket, not a live carrier rate. Nothing is created and nothing is charged.

Try it
curl -X POST https://api.egful.store/api/v1/orders/quote \
  -H "X-API-Key: egk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
  "items": [
    {
      "product_id": "16468",
      "quantity": 2,
      "color": "Black",
      "size": "L",
      "method": "DTG"
    }
  ]
}'
Response
{
  "object": "quote",
  "mode": "live",
  "currency": "USD",
  "lines": [
    { "line": 1, "sku": "16468", "product": "Heavy Cotton Tee", "size": "L",
      "method": "DTG", "quantity": 2, "unit_price": 8.50, "line_total": 17.00 }
  ],
  "totals": { "items": 17.00, "shipping": 7.99, "discount": { "percent": 20, "amount": 3.40 },
    "total": 21.59, "units": 2 }
}
POST/api/v1/orders/:id/cancelCancel order

Cancels an order that has not reached production and returns everything still owed to your balance, releasing the units it had reserved. Refused with 409 once the order is approved — the same rule the app itself applies, so a partner and a seller can never get different answers about the same order. Cancelling twice answers 200 with the current state rather than an error, so a retried timeout is safe.

Try it
curl -X POST https://api.egful.store/api/v1/orders/ord_test123/cancel \
  -H "X-API-Key: egk_test_..."
Response
{
  "object": "order",
  "mode": "live",
  "id": "API-9F2C1A",
  "status": "cancelled",
  "refunded": 21.59
}
GET/api/v1/orders/:idRetrieve order

Looks up an order by id. A test key resolves ANY well-formed id to a simulated order with the same fields live returns — `total` comes back null there, because the id matches no real order. A cancelled order also carries `reason`, `rejected_by` and `rejected_at`, so a refusal is readable here even if the webhook never arrived.

Try it
curl -X GET https://api.egful.store/api/v1/orders/ord_test123 \
  -H "X-API-Key: egk_test_..."
Response
{
  "object": "order",
  "mode": "live",
  "id": "API-9F2C1A",
  "status": "shipped",
  "tracking": { "carrier": "USPS", "code": "9400100000000000000000" },
  "total": 17.00,
  "created": "2026-07-21T16:04:11.882Z"
}
GET/api/v1/stockCheck stock

What we can make right now. Returns available quantity per blank sku plus a status band (in_stock / low / out_of_stock). Pass ?sku= to check one. Available is on-hand minus already committed; stock is held per BLANK, so a print-method suffix (-EMB, -DTG, …) is stripped before matching.

Try it
curl -X GET https://api.egful.store/api/v1/stock \
  -H "X-API-Key: egk_test_..."
Response
{
  "object": "list",
  "mode": "test",
  "count": 2,
  "data": [
    { "sku": "16468", "name": "Heavy Cotton Tee", "variant": "Black / L",
      "category": "Apparel", "available": 90, "status": "in_stock" },
    { "sku": "LA6", "name": "Trucker Cap", "variant": "Navy",
      "category": "Headwear", "available": 12, "status": "low" }
  ]
}
GET/api/v1/balanceAccount balance

What is currently on account. Negative means charges exceed funds. Needs the billing.read scope.

Try it
curl -X GET https://api.egful.store/api/v1/balance \
  -H "X-API-Key: egk_test_..."
Response
{
  "object": "balance",
  "mode": "live",
  "account": "8f3c…",
  "balance": 90.80,
  "currency": "USD"
}
GET/api/webhooksList webhooks

Your registered endpoints. Secrets are never returned here — they're shown once, when the endpoint is created.

Try it
curl -X GET https://api.egful.store/api/webhooks \
  -H "X-API-Key: egk_test_..."
Response
[
  { "id": 3,
    "url": "https://your-app.example.com/hooks/egful",
    "events": ["order.received", "order.shipped"],
    "active": true,
    "created_at": "2026-07-21T14:00:00.000Z" }
]
POST/api/webhooksAdd a webhook

Register an https endpoint to be notified on. Returns a signing secret ONCE — store it. Omit `events` to receive all of them. Every delivery carries X-EG-Event and X-EG-Signature (sha256=<hex>), an HMAC-SHA256 of the raw body using that secret; compare it in constant time before trusting a payload.

Try it
curl -X POST https://api.egful.store/api/webhooks \
  -H "X-API-Key: egk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://your-app.example.com/hooks/egful",
  "events": [
    "order.received",
    "order.status_changed",
    "order.shipped",
    "order.cancelled"
  ]
}'
Response
{
  "id": 4,
  "url": "https://your-app.example.com/hooks/egful",
  "events": ["order.shipped"],
  "active": true,
  "secret": "egwh_2f9c1d4b…",
  "_note": "Store this secret now — it is not shown again."
}
GET/api/webhooks/:id/deliveriesDelivery attempts

The last 100 attempts for one endpoint — status code, error and attempt count. Deliveries retry three times with backoff and give up on a non-retryable 4xx, so this is where a missed notification is explained.

Try it
curl -X GET https://api.egful.store/api/webhooks/1/deliveries \
  -H "X-API-Key: egk_test_..."
Response
[
  { "id": 11, "event": "order.shipped", "status_code": 200,
    "error": null, "attempts": 1, "created_at": "2026-07-21T15:16:00.000Z" },
  { "id": 10, "event": "order.received", "status_code": 500,
    "error": null, "attempts": 3, "created_at": "2026-07-21T15:02:00.000Z" }
]

Billing

GET /api/v1/balance returns what is currently on account.GET /api/v1/statement?from=YYYY-MM-DD&to=YYYY-MM-DD returns every movement in a period, defaulting to the current calendar month. Both need billing.read.

{
  "object": "statement",
  "period": { "from": "2026-07-01", "to": "2026-07-31" },
  "opening_balance": 12.30,
  "closing_balance": 90.80,
  "totals": { "charges": -46.00, "credits": 124.50, "net": 78.50 },
  "lines": [
    { "id": "1", "date": "2026-07-02T09:14:22.104Z", "type": "order-out",
      "order_id": "API-9F2C1A", "description": "Order API-9F2C1A",
      "amount": -24.50, "balance": -12.20 }
  ]
}

Charges are negative, credits positive, and every line carries the running balance after it. opening_balance + totals.net always equalsclosing_balance — if it does not, tell us rather than working around it.

There is no separate invoice object, deliberately. The ledger is append-only, so a statement for a closed period cannot change after the fact; inventing an invoice record alongside it would create a second thing that can disagree about what is owed.

Webhooks

Rather than polling for status, register a URL and we POST to it when something happens. Add one in your dashboard under Developers → Webhooks, where you can also fire a test delivery and read the history of every attempt.

EventFires when
order.receivedWe accepted an order you pushed.
order.status_changedIt moved along the production pipeline.
order.shippedTracking exists. This is the one most integrations care about.
order.cancelledIt was cancelled or refunded — or we refused it. Carries reason and rejected_by.
POST https://your-app.example.com/hooks/egful
X-EG-Event: order.shipped
X-EG-Signature: sha256=<hex>

{
  "event": "order.shipped",
  "created": "2026-07-21T14:56:43.584Z",
  "data": {
    "id": "API-9F2C1A",
    "status": "shipped",
    "tracking": { "carrier": "USPS", "code": "9400100000000000000000" },
    "total": 24.50
  }
}

Your endpoint must be public https and should answer 2xx quickly — we abort a delivery after 10 seconds. Acknowledge first, then do the slow work.

Failed deliveries retry three times with backoff. A 5xx, 429 or408 is treated as transient; any other 4xx is a rejection and we stop. Because retries exist, the same event can arrive more than once — make processing idempotent. Deliveries are independent, so do not assume ordering; treat each payload as the current state rather than a diff.

Verifying signatures

Anyone can POST to your URL. The signature is the only thing that proves a delivery came from us. It is an HMAC-SHA256 of the raw request body, keyed with the secret shown once when you created the endpoint.

import crypto from "node:crypto"

// express.raw() — NOT express.json(). Re-serialising the body
// changes whitespace and key order, and the digest will not match.
app.post("/hooks/egful", express.raw({ type: "application/json" }), (req, res) => {
  const presented = String(req.headers["x-eg-signature"] || "").replace("sha256=", "")
  const expected  = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex")

  const a = Buffer.from(presented), b = Buffer.from(expected)
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401)

  res.sendStatus(200)                        // acknowledge first
  process(JSON.parse(req.body.toString()))   // then work
})

Compare in constant time. A plain === leaks timing information about the expected value.

Errors

Every error returns a JSON body with error in plain words and a code to switch on. A validation failure also names the fields it is missing under missing; an order we cannot price names the lines under unpriced.

{
  "error": "An order needs a non-empty "items" array.",
  "code": "invalid_request",
  "mode": "live",
  "missing": ["items"]
}
{
  "error": "Some lines have no catalogue match, so they cannot be priced or produced.",
  "code": "unpriceable_lines",
  "unpriced": [{ "line": 2, "sku": "NOT-OURS", "size": "L", "method": "DTG" }]
}
  • 400 — the request is wrong. The body names what.
  • 401 — missing or revoked key.
  • 429 — rate limited. Wait Retry-After seconds.
  • 501 — a documented capability we do not offer. Carrier label purchasing is one: we buy labels ourselves when shipping your order, so read tracking from the order instead.

Every line of an order must resolve to a product in our catalogue. We refuse an order we cannot price rather than inventing a number and producing it — check your SKUs against GET /api/v1/products.

If we refuse an order after accepting it

Some things are only discovered on the floor — a blank out of stock in one colour, artwork that cannot be produced at the size ordered. When that happens the order is cancelled and you are told why, both on the order.cancelled event and on the order itself, so a webhook you missed is not a reason you never learn.

GET /api/v1/orders/API-9F2C1A

{
  "object": "order",
  "id": "API-9F2C1A",
  "status": "cancelled",
  "reason": "Blank out of stock in Navy 2XL",
  "rejected_by": "factory",
  "rejected_at": "2026-07-21T17:04:11.882Z"
}

rejected_by is factory when we refused it and seller when it was cancelled from your side — they are the same event otherwise, and you almost certainly want to treat them differently.