Skip to content
How it works Pricing Live demo Log in Open your shop

Help topics

Help topics
  1. Getting started
  2. Products, stock and options
  3. Orders, fulfilment, packing slips and invoices
  4. Payments
  5. Delivery options and provinces
  6. Booking a courier
  7. Discounts
  8. Gift cards
  9. Your shop's look
  10. Sections
  11. Custom domain
  12. Customers, reviews and stock alerts
  13. Email marketing
  14. Ads and tracking (Meta and Google)
  15. Apps and add-ons
  16. Connect your AI
  17. Webhooks
  18. The API
  19. Plan and billing
  20. Account, password and security

Help centre

The API

The API lets your own software read and change your shop: keep stock in step with a spreadsheet, push new products from your supplier's list, pass orders to a courier or pull your sales into your accounting tool. It is for people who have a developer or a tool that can call a web address. If you do not, you can ignore this page.

You get one key per tool, and each key can only do the things you tick. Find them under Settings, API keys. Only the shop owner can open that page. API keys are part of the free Developer tools pack: if you can't see them, add it under Apps, Tool packs.

This is separate from two other things. The order intake key in Settings is for a website hosted elsewhere that sends orders in. Webhooks tell your systems when something happens; the API is how your systems ask or act.

Make a key

  • Step 1: Open Settings, API keys.
  • Step 2: Give the key a name, like Stock sync, so you can tell your keys apart later. The name also shows in your activity log.
  • Step 3: Tick what the key may do, then press Make key.
  • Step 4: Copy the key on the next page. It is shown once. We keep only a scrambled copy, so we cannot show it again. If you lose it, revoke it and make a new one.

A key looks like sf_live_ followed by 32 letters and numbers. You can have up to 20 keys. Press Revoke on a key to stop it working straight away. A revoked key stays on the page, greyed out, so your activity log still makes sense. The page also shows when each key was last used.

Treat a key like a password. Keep it on your server or in your tool's secret settings. Never put it in a web page, a mobile app or a public repository, because anyone who has it can do what it is allowed to do.

Changing your password does not revoke keys, so your software keeps working. If a key may have leaked, or someone who could see it has left, revoke it and make a new one.

Send your key

Every request carries the key in an Authorization header. The base address is https://shops.forgelab.co.za/api/v2 and every address below starts with it.

curl https://shops.forgelab.co.za/api/v2/products \
  -H "Authorization: Bearer sf_live_your_key_here"

Requests and answers are JSON. For a POST or PATCH, send a JSON body. Every answer, including an error, is JSON.

Permissions

A key has one or more permissions, called scopes. A scope that changes things does not include the matching read scope, so a key that only pushes stock counts cannot read your customers. Tick both when your software needs both.

  • read_products: see products, variants, prices and collections.
  • write_products: create, edit and delete products.
  • read_stock: see stock counts and the stock history of a product.
  • write_stock: set or adjust stock counts.
  • read_orders: see orders, their items, delivery details and timeline.
  • write_orders: mark orders fulfilled, cancel unpaid orders and add notes.
  • read_customers: see customer names, emails, phone numbers and addresses.
  • write_customers: change a customer's private note and tags.
  • read_reports: see the sales report, with VAT.

A request that needs a scope the key does not have is refused with status 403.

Amounts, times and ids

  • Amounts are whole cents, so R149.99 is 14999. Never decimals.
  • Times are UTC, written like 2026-10-11T08:15:02Z or 2026-10-11 08:15:02. South Africa is two hours ahead.
  • Products, customers and orders have a number called id. An order can also be found by its reference, like SF-8F3K2A, anywhere an order id goes.

Pages of results

Lists are newest first and hold up to 50 items. Ask for fewer with limit. When there is more, the answer has a next value. Send it back as cursor to get the following page. When next is null you have everything.

GET /api/v2/orders?limit=2

{
  "data": [ { "id": 902, "ref": "SF-8F3K2A" }, { "id": 901, "ref": "SF-71AB9C" } ],
  "next": "aWQ6OTAx"
}

GET /api/v2/orders?limit=2&cursor=aWQ6OTAx

The cursor is opaque. Do not build one yourself. The example above shows only two fields of each order to keep it short.

Products

Needs read_products to read and write_products to change.

  • GET /products lists products. Filters: q (words in the name, type, tags or SKU), status (active or hidden) and collection (a collection's handle, name or id).
  • GET /products/{id} returns one product.
  • POST /products creates a product. Send name and price_cents. Optional: compare_at_cents (the old price, to show a sale), description (plain text or basic formatting), sku, product_type, tags (a list), stock, weight_grams, made_to_order (true or false), digital (true for a product the customer downloads; it never sells out, and you add its files in the dashboard), collections (a list of handles), image_urls (up to 3 public https links), seo_title, seo_description and active. A new product is hidden until you send active as true.
  • PATCH /products/{id} changes the fields you send and leaves the rest. It takes the same fields as create, except stock and collections; use add_to_collections and remove_from_collections (lists of handles) for collections. Stock has its own address below so every change lands in the stock history.
  • DELETE /products/{id} deletes the product and its stock history. This cannot be undone.

A product looks like this. It is the same document a product webhook sends, plus made_to_order, digital, weight_grams, on_hand (what can really be sold, adding up variants), collections and link (the product's page on your shop).

{
  "data": {
    "id": 31,
    "name": "Ethiopia 250g",
    "slug": "ethiopia-250g",
    "description": "Washed, bright and fruity.",
    "price_cents": 14000,
    "compare_at_cents": 0,
    "sku": "ETH-250",
    "barcode": "",
    "stock": 24,
    "active": true,
    "product_type": "Coffee",
    "tags": ["single origin"],
    "image_url": "",
    "created_at": "2026-10-01 09:00:00",
    "variants": [],
    "made_to_order": false,
    "digital": false,
    "weight_grams": 250,
    "on_hand": 24,
    "collections": ["coffee"],
    "link": "/s/koppie-coffee/p/31/ethiopia-250g"
  }
}

Creating, changing and deleting a product send the usual product.created, product.updated and product.deleted webhooks.

Stock

  • GET /products/{id}/stock needs read_stock. It returns the count, the variants' counts and the newest 50 entries of the stock history.
  • POST /products/{id}/stock needs write_stock. Send set_to (the new count) or adjust_by (a change like -2 or 10), never both. If the product has variants, also send variant_id. Optional: reason, up to 120 characters, kept in the stock history.
POST /api/v2/products/31/stock
{ "adjust_by": -3, "reason": "Sold at the market" }

{ "data": { "product_id": 31, "variant_id": null, "was": 24, "now": 21, "reason": "Sold at the market" } }

The change shows in the product's stock history as Set from the API. Taking away more than you have is refused with status 409 and nothing changes. A bundle has no stock of its own, so change the stock of its parts.

Orders

Needs read_orders to read and write_orders to change.

  • GET /orders lists orders. Filters: status (pending, paid, fulfilled or cancelled), since (placed on or after a date or time) and updated_since (placed, paid or changed on or after a date or time). updated_since is the one to use to keep another system in step.
  • GET /orders/{id} returns one order with its events, the timeline including private notes.
  • POST /orders/{id}/fulfil marks the order fulfilled and emails the customer, exactly as the dashboard does. Optional: courier, tracking_number and tracking_url. Sending a courier needs a tracking number. If the order is already fulfilled nothing is sent again.
  • POST /orders/{id}/cancel cancels an order that has not been paid. Optional: reason. A paid order is refused with status 409, because the money and the stock have to be put right first: record the refund in the dashboard, then cancel. The API never refunds anything.
  • POST /orders/{id}/note adds a private note to the order. Send note. The customer never sees it.

The answer to fulfil and cancel has the order in data and a meta object, for example emailed_customer or already_cancelled.

An order is the same document an order webhook sends, plus id and archived. Cards and PayPal checkouts that were never paid are listed as pending, like in your dashboard.

{
  "data": {
    "id": 902,
    "ref": "SF-8F3K2A",
    "status": "paid",
    "currency": "ZAR",
    "payment_method": "card",
    "source": "storefront",
    "created_at": "2026-10-11 08:10:44",
    "paid_at": "2026-10-11 08:12:03",
    "subtotal_cents": 28000,
    "discount_cents": 0,
    "delivery_cents": 6000,
    "total_cents": 34000,
    "refunded_cents": 0,
    "items": [
      { "name": "Ethiopia 250g", "sku": "ETH-250", "qty": 2, "price_cents": 14000,
        "discount_cents": 0, "product_id": 31, "variant_id": null }
    ],
    "customer": { "id": 77, "name": "Thandi Dlamini", "email": "[email protected]", "phone": "0821234567" },
    "delivery": { "kind": "ship", "option": "Courier", "street": "12 Main Rd", "city": "Pretoria",
                  "tracking_ref": "", "tracking_url": "" },
    "archived": false
  }
}

Fulfilling and cancelling send the usual order.fulfilled and order.cancelled webhooks.

Customers

Needs read_customers to read and write_customers to change.

  • GET /customers lists customers. Filters: q (words in the name, phone, email or tags) and tag.
  • GET /customers/{id} returns one customer with their latest 10 orders in recent_orders.
  • PATCH /customers/{id} changes the private note and the tags. Send note, or tags (replaces the list), or add_tags and remove_tags. These are for you only; the customer never sees them.

A customer is the same document a customer webhook sends, plus orders_count (paid orders), total_spent_cents, note and tags.

Sales report

  • GET /reports/sales needs read_reports. Send from and to as dates like 2026-01-01. Without them you get the last 12 months.

It returns the numbers on your Sales report page, month by month and in total: orders, gross, refunded and net sales, and VAT. Sales are counted on the day they were paid and refunds on the day they were recorded. VAT figures are zero unless your shop has a VAT number saved.

{
  "data": {
    "from": "2026-01-01",
    "to": "2026-10-11",
    "currency": "ZAR",
    "vat_registered": true,
    "vat_rate": 15.0,
    "months": [
      { "month": "2026-09", "orders": 41, "gross_cents": 1250000, "refunded_cents": 30000,
        "net_cents": 1220000, "vat_cents": 159130 }
    ],
    "totals": { "orders": 41, "gross_cents": 1250000, "refunded_cents": 30000,
                "net_cents": 1220000, "vat_cents": 159130 }
  }
}

Errors

A problem always comes back as JSON with a status code, a short code to program against and a message a person can read.

{ "error": { "code": "insufficient_scope", "message": "This key does not have the write_stock scope." } }
  • 400: the request is wrong. Codes: invalid_request, invalid_json, invalid_cursor. The message says which field.
  • 401: no key, a key we do not know, or a revoked key. Codes: missing_key, invalid_key, key_revoked.
  • 403: the key is real but not allowed. Codes: insufficient_scope, store_suspended.
  • 404: there is no such address, or no product, order or customer with that id in your shop. Code: not_found. Another shop's ids look exactly the same as ids that do not exist.
  • 405: the address does not take that method. Code: method_not_allowed.
  • 409: the request is fine but the shop's state stops it. Codes: order_paid, gift_card_not_returned, order_cancelled, insufficient_stock.
  • 413: the body is too large. Code: too_large.
  • 429: too many requests. Code: rate_limited.
  • 500: something went wrong on our side. Code: server_error. Try again.

Limits

  • Each key may make 600 requests in 10 minutes. After that you get status 429 and a Retry-After header with the seconds to wait.
  • Wrong keys count against the address they came from: 60 in 10 minutes.
  • A request body can be up to 512 KB.
  • A shop that ForgeLab has switched off cannot use the API.

What the API does not do

  • It does not refund, mark an order paid or change payment settings.
  • It cannot cancel a paid order. Record the refund in the dashboard first.
  • It does not make or revoke keys. Only the owner does that, on the API keys page.
  • It does not place orders. A website that sends orders in uses the order intake key from Settings.

The activity log

Every change made with a key is a line in Settings, Activity, shown as api: followed by the key's name, so you can see which tool did what. Reading is not logged. Changes also send your webhooks, so a stock change made through the API reaches your other systems like any other.

Example: Python

This sets the stock of one product and then lists orders that need sending. It needs the requests library.

import os
import requests

BASE = "https://shops.forgelab.co.za/api/v2"
HEAD = {"Authorization": "Bearer " + os.environ["SHOP_API_KEY"]}

def call(method, path, **kw):
    r = requests.request(method, BASE + path, headers=HEAD, timeout=30, **kw)
    if r.status_code >= 400:
        err = r.json()["error"]
        raise RuntimeError(f"{r.status_code} {err['code']}: {err['message']}")
    return r.json()

call("POST", "/products/31/stock", json={"set_to": 40, "reason": "Stock take"})

page = call("GET", "/orders", params={"status": "paid"})
for order in page["data"]:
    print(order["ref"], order["customer"]["name"], order["total_cents"] / 100)

if page["data"]:
    ref = page["data"][0]["ref"]
    call("POST", f"/orders/{ref}/fulfil", json={"courier": "Courier Guy", "tracking_number": "CG123456"})

Example: curl

curl -X POST https://shops.forgelab.co.za/api/v2/products \
  -H "Authorization: Bearer $SHOP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Rooibos 100g", "price_cents": 6500, "stock": 12, "active": true}'

Still stuck? [email protected] or WhatsApp 073 429 5517.

Online shops for South African businesses.

Card payments go straight into your own Yoco or PayGate account. Built in South Africa.

Product

Live demo shop Pricing Storeforge vs Shopify Open your shop Log in

Support

Help centre Status [email protected]

Legal

Merchant terms Privacy