Skip to Content

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.

  1. In your seller space, open API and webhooks.
  2. Create a key: “read only” to read orders and products, or “read and write” to also create and cancel orders.
  3. 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"]}}
StatusMeaning
400The request is malformed (a parameter, the JSON).
401Missing, wrong or revoked key.
403The key is read-only.
404No such order in your account.
409The order cannot change any more: already cancelled, left with the carrier, or being called.
422A field is missing or wrong, or a product is unknown.
429More 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).

ParameterMeaning
statusOnly the orders in this status (see Order statuses).
updated_sinceOnly the orders changed since this date: the simplest way to stay in sync.
created_since, created_beforeOrders received in this period.
referenceThe order with your own reference.
phoneThe orders of this customer number.
page, limitThe 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
  }'
FieldMeaning
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.phone2A second number.
address.line *Street, building, landmarks.
address.city, address.governorateCity and governorate, in French, Arabic or English.
items *The products: sku (a product or a pack of GET /products), qty and price.
totalThe 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

statusMeaning
to_confirmWaiting for our call centre (new, call back, unreachable).
confirmedConfirmed by the customer, not prepared yet.
to_shipBeing prepared in our warehouse.
shippedHanded to the carrier.
in_transitOn its way to the customer.
deliveredDelivered and paid by the customer.
returningRefused or not delivered, on its way back.
returnedBack in our warehouse.
cancelledCancelled.

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.

EventWhen
order.createdA new order reached your account (store, API or by hand).
order.confirmedThe customer confirmed it on the phone.
order.cancelledIt was cancelled.
order.shippedThe parcel was handed to the carrier.
order.in_transitThe parcel is on its way.
order.deliveredDelivered and paid.
order.returningRefused or not delivered: it comes back.
order.returnedBack in our warehouse.
pingThe 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.

Cookies

We only use the cookies the site needs to work: your sign-in, your language and your display choices. No advertising cookie. With your consent, we may also measure the audience to improve the platform.