Developers
Fulfiller API and webhooks
Connect your store, your management software or your automation tool to Fulfiller: send orders, follow their status and your stock, and receive an event as soon as an order changes.
Start
The API is open to sellers whose account is approved.
- In your seller space, open API and webhooks.
- Create a key: “read only” to read orders and products, or “read and write” to also create and cancel orders.
- Copy the key: it is shown only once. Keep it on your server, never in a web page or a mobile app.
Address of the API: https://fulfiller.net/api/v1
Requests and answers are JSON (UTF-8). Dates are ISO 8601 in UTC, amounts in Tunisian dinars (TND).
curl https://fulfiller.net/api/v1/me \
-H "Authorization: Bearer ffk_your_key"
{"account": {"id": 42, "name": "Najma Store"},
"key": {"name": "My ERP", "scope": "write", "ends_with": "x9Qa"},
"webhooks": 1}
Authentication
Send the key in the Authorization header, after the word Bearer. A missing, wrong or revoked key gets the status 401; a read-only key that tries to change an order gets 403.
Authorization: Bearer ffk_your_key
Revoke a key at any time in API and webhooks: it stops working at once.
Errors and limits
An error answers with an HTTP status and a JSON body:
{"error": {"code": "unknown_product", "message": "Unknown product(s): KETO-90. Use the SKUs of GET /api/v1/products.", "skus": ["KETO-90"]}}
| Status | Meaning |
|---|---|
400 | The request is malformed (a parameter, the JSON). |
401 | Missing, wrong or revoked key. |
403 | The key is read-only. |
404 | No such order in your account. |
409 | The order cannot change any more: already cancelled, left with the carrier, or being called. |
422 | A field is missing or wrong, or a product is unknown. |
429 | More than 120 requests in a minute: wait the seconds given in the Retry-After header. |
Read orders
GET /orders
Your orders, newest first, 50 per page (up to 100 with limit).
| Parameter | Meaning |
|---|---|
status | Only the orders in this status (see Order statuses). |
updated_since | Only the orders changed since this date: the simplest way to stay in sync. |
created_since, created_before | Orders received in this period. |
reference | The order with your own reference. |
phone | The orders of this customer number. |
page, limit | The page wanted and its size. |
curl "https://fulfiller.net/api/v1/orders?status=delivered&updated_since=2026-10-01T00:00:00Z" \
-H "Authorization: Bearer ffk_your_key"
{"data": [{
"id": 1234, "number": "FF-001234", "reference": "1042", "source": "api", "store": null,
"status": "delivered", "confirmation_status": "confirmed", "delivery_status": "delivered",
"customer": {"name": "Amina B.", "phone": "+21698123456", "phone2": null},
"address": {"line": "Route de Tunis km 5", "city": "Sakiet Ezzit", "governorate": "sfax", "governorate_name": "Sfax"},
"total": 89.0, "currency": "TND",
"items": [{"sku": "KETO-60", "name": "Keto 60 caps", "qty": 2, "price": 44.5}],
"carrier": "First Delivery", "tracking_number": "FD123456789", "return_reason": null,
"recovered_cart": false,
"created_at": "2026-10-28T09:12:03Z", "confirmed_at": "2026-10-28T10:01:44Z", "updated_at": "2026-10-30T15:20:11Z"}],
"page": 1, "limit": 50, "total": 1, "has_more": false}
GET /orders/{number}
One order, by its Fulfiller number (FF-001234) or its id, with the carrier's tracking history in history.
Create an order
POST /orders read and write
Send the order as your store received it. Our call centre calls the customer to confirm it, then we pack, ship and collect the cash, as for your other orders.
curl -X POST https://fulfiller.net/api/v1/orders \
-H "Authorization: Bearer ffk_your_key" -H "Content-Type: application/json" \
-d '{
"reference": "1042",
"customer": {"name": "Amina B.", "phone": "98 123 456"},
"address": {"line": "Route de Tunis km 5", "city": "Sakiet Ezzit", "governorate": "Sfax"},
"items": [{"sku": "KETO-60", "qty": 2, "price": 44.5}],
"total": 89
}'
| Field | Meaning |
|---|---|
reference * | Your own unique number for the order. Sent again, it gives back the order already created: nothing is created twice. |
customer.name * | The customer's name. |
customer.phone * | A Tunisian number, written any way. |
customer.phone2 | A second number. |
address.line * | Street, building, landmarks. |
address.city, address.governorate | City and governorate, in French, Arabic or English. |
items * | The products: sku (a product or a pack of GET /products), qty and price. |
total | The amount to collect; without it, the sum of the items. |
type | “order”, or “abandoned_cart” for a checkout the customer did not finish: our call centre calls them 30 minutes later to offer to confirm it. |
The answer is 201 with the order when it is created, 200 with the order already known when the reference was sent before. An unknown SKU gets 422 with the list of unknown products.
Cancel an order
POST /orders/{number}/cancel read and write
For a customer who no longer wants the order. Before confirmation it is cancelled at once; after confirmation, only while the parcel is still in our warehouse. Give the reason.
curl -X POST https://fulfiller.net/api/v1/orders/FF-001234/cancel \
-H "Authorization: Bearer ffk_your_key" -H "Content-Type: application/json" \
-d '{"reason": "The customer ordered twice"}'
Products and stock
GET /products
What you can sell, with the SKU to put in your orders: your own products, the marketplace products of your catalog (at your selling price) and your packs. stock.available is the number of units free to sell in our warehouse; stock.status is ok, low, out, or order (supplied on order).
{"data": [
{"type": "marketplace", "sku": "KETO-60", "name": "Keto 60 caps", "price": 44.5,
"stock": {"available": 120, "status": "ok"}},
{"type": "pack", "sku": "PACK-DUO", "name": "Duo pack", "price": 79.0,
"items": [{"sku": "KETO-60", "qty": 2}], "stock": null}
]}
Order statuses
| status | Meaning |
|---|---|
to_confirm | Waiting for our call centre (new, call back, unreachable). |
confirmed | Confirmed by the customer, not prepared yet. |
to_ship | Being prepared in our warehouse. |
shipped | Handed to the carrier. |
in_transit | On its way to the customer. |
delivered | Delivered and paid by the customer. |
returning | Refused or not delivered, on its way back. |
returned | Back in our warehouse. |
cancelled | Cancelled. |
confirmation_status and delivery_status give the detail of each step.
Webhooks
A webhook posts an event to your address as soon as an order changes, so you do not have to ask the API again and again. Add up to 5 https addresses in API and webhooks and choose the events of each.
| Event | When |
|---|---|
order.created | A new order reached your account (store, API or by hand). |
order.confirmed | The customer confirmed it on the phone. |
order.cancelled | It was cancelled. |
order.shipped | The parcel was handed to the carrier. |
order.in_transit | The parcel is on its way. |
order.delivered | Delivered and paid. |
order.returning | Refused or not delivered: it comes back. |
order.returned | Back in our warehouse. |
ping | The test sent by the “Send a test” button. |
POST https://your-site.example/fulfiller/webhook
Content-Type: application/json; charset=utf-8
X-Fulfiller-Event: order.delivered
X-Fulfiller-Delivery: evt_5f0c2a9d4b7e4f61a2c3d4e5f6a7b8c9
X-Fulfiller-Timestamp: 1793372411
X-Fulfiller-Signature: sha256=4c1f…
{"id": "evt_5f0c2a9d4b7e4f61a2c3d4e5f6a7b8c9", "event": "order.delivered",
"created_at": "2026-10-30T15:20:11Z",
"data": {"id": 1234, "number": "FF-001234", "reference": "1042", "status": "delivered", …}}
data is the order as GET /orders/{number} shows it, read when the event is first sent.
- X-Fulfiller-Delivery is the event's own id: the same on every new try. Keep it to ignore an event you already handled.
- Answer with a 2xx status within 10 seconds, then do your work. Any other answer, or none, is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours.
- A webhook that fails for a whole day is paused and you are notified. Resume it in API and webhooks: the events waiting are sent.
- Events can arrive out of order or twice: rely on status and updated_at in data.
Check a signature
Each webhook has its own secret (whsec_…), shown in API and webhooks. Compute the HMAC-SHA256 of the timestamp, a dot and the raw body, with the secret as key, and compare it with the value after sha256= in X-Fulfiller-Signature. Refuse a timestamp older than 5 minutes.
Python
import hashlib, hmac, time
def is_from_fulfiller(secret, headers, raw_body):
stamp = headers["X-Fulfiller-Timestamp"]
if abs(time.time() - int(stamp)) > 300:
return False
expected = hmac.new(secret.encode(), stamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
given = headers.get("X-Fulfiller-Signature", "").removeprefix("sha256=")
return hmac.compare_digest(expected, given)
PHP
$raw = file_get_contents('php://input');
$stamp = $_SERVER['HTTP_X_FULFILLER_TIMESTAMP'] ?? '0';
$given = substr($_SERVER['HTTP_X_FULFILLER_SIGNATURE'] ?? '', 7);
$expected = hash_hmac('sha256', $stamp . '.' . $raw, $secret);
if (abs(time() - (int) $stamp) > 300 || !hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true); // $event['event'], $event['data']['status']…
Node.js (Express)
const crypto = require('crypto');
app.post('/fulfiller/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const stamp = req.get('X-Fulfiller-Timestamp') || '0';
const given = (req.get('X-Fulfiller-Signature') || '').replace('sha256=', '');
const expected = crypto.createHmac('sha256', process.env.FULFILLER_SECRET)
.update(stamp + '.').update(req.body).digest('hex');
const same = given.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!same || Math.abs(Date.now() / 1000 - Number(stamp)) > 300) return res.sendStatus(401);
res.sendStatus(200);
const event = JSON.parse(req.body); // event.event, event.data.status…
});
Without a developer
Connect your store in Connected stores: Shopify, WooCommerce, YouCan, TikTak PRO, Converty or a Google Sheet. The orders, and the abandoned carts if you wish, arrive on their own.