egful API
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, flaggedtest: 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.
| Scope | Allows |
|---|---|
| orders.write | Create orders. |
| orders.read | Read an order's status and tracking. |
| products.read | List the blanks you can order. |
| webhooks.read | List endpoints and their delivery history. |
| webhooks.write | Add, remove and test endpoints. |
| billing.read | Read 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
| Scope | Limit |
|---|---|
| All endpoints | 600 requests / minute / key |
| Order creation | 60 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.
/api/v1/pingValidate keyChecks your test key and confirms the sandbox is reachable.
curl -X GET https://api.egful.store/api/v1/ping \ -H "X-API-Key: egk_test_..."
{
"ok": true,
"mode": "test",
"live": false,
"seller_id": "8f3c…",
"time": "2026-07-21T16:04:11.882Z",
"message": "Sandbox reachable — your test key is valid."
}/api/v1/productsList productsThe 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.
curl -X GET https://api.egful.store/api/v1/products \ -H "X-API-Key: egk_test_..."
{
"object": "list",
"mode": "test",
"count": 2,
"data": [
{ "id": "SS-16468", "sku": "16468", "name": "Heavy Cotton Tee",
"type": "Apparel", "method": "DTG", "base_price": 8.50 }
]
}/api/v1/ordersCreate orderCreate 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.
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"
}
}'{
"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"
}/api/v1/orders/quoteQuote an orderWhat 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.
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"
}
]
}'{
"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 }
}/api/v1/orders/:id/cancelCancel orderCancels 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.
curl -X POST https://api.egful.store/api/v1/orders/ord_test123/cancel \ -H "X-API-Key: egk_test_..."
{
"object": "order",
"mode": "live",
"id": "API-9F2C1A",
"status": "cancelled",
"refunded": 21.59
}/api/v1/orders/:idRetrieve orderLooks 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.
curl -X GET https://api.egful.store/api/v1/orders/ord_test123 \ -H "X-API-Key: egk_test_..."
{
"object": "order",
"mode": "live",
"id": "API-9F2C1A",
"status": "shipped",
"tracking": { "carrier": "USPS", "code": "9400100000000000000000" },
"total": 17.00,
"created": "2026-07-21T16:04:11.882Z"
}/api/v1/stockCheck stockWhat 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.
curl -X GET https://api.egful.store/api/v1/stock \ -H "X-API-Key: egk_test_..."
{
"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" }
]
}/api/v1/balanceAccount balanceWhat is currently on account. Negative means charges exceed funds. Needs the billing.read scope.
curl -X GET https://api.egful.store/api/v1/balance \ -H "X-API-Key: egk_test_..."
{
"object": "balance",
"mode": "live",
"account": "8f3c…",
"balance": 90.80,
"currency": "USD"
}/api/webhooksList webhooksYour registered endpoints. Secrets are never returned here — they're shown once, when the endpoint is created.
curl -X GET https://api.egful.store/api/webhooks \ -H "X-API-Key: egk_test_..."
[
{ "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" }
]/api/webhooksAdd a webhookRegister 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.
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"
]
}'{
"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."
}/api/webhooks/:id/deliveriesDelivery attemptsThe 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.
curl -X GET https://api.egful.store/api/webhooks/1/deliveries \ -H "X-API-Key: egk_test_..."
[
{ "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.
| Event | Fires when |
|---|---|
| order.received | We accepted an order you pushed. |
| order.status_changed | It moved along the production pipeline. |
| order.shipped | Tracking exists. This is the one most integrations care about. |
| order.cancelled | It 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. WaitRetry-Afterseconds.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.