Overview#
Treez gives your customers points for their purchases. Every 100 points plants a real, GPS-verified native tree. There are two ways to send us orders — pick whichever fits your store:
Salla, Zid, WooCommerce, Shopify: connect from the portal, no code.
Any other website, app or POS: one HTTPS call per order.
Base URL: https://api.treezplus.com/v1. JSON in and out, camelCase fields, ISO-8601 UTC timestamps. Lists use ?limit=&cursor= and return { items, nextCursor }.
Authentication#
Create API keys in the store portal under Developers → API keys. Send the key as a bearer token from your server:
Authorization: Bearer sk_live_4f8a…| Key | What it does |
|---|---|
sk_test_… | Orders are recorded with livemode: false and fire webhooks, but never touch your budget or customers' wallets. Available while your store is pending approval. |
sk_live_… | Real orders and real points. Requires an approved store. |
Order API#
Send an order when it's paid (or completed — your choice in Settings → Earning rules). Sending the same externalOrderId again updates the order instead of creating a duplicate.
/v1/orderscurl -X POST https://api.treezplus.com/v1/orders \
-H "Authorization: Bearer sk_test_…" \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/json" \
-d '{
"externalOrderId": "1001",
"amount": 12.5,
"currency": "OMR",
"status": "paid",
"customer": { "email": "salim@example.com", "name": "Salim", "phone": "+96890000001" },
"occurredAt": "2026-10-24T09:30:00Z"
}'Request fields
externalOrderIdstring | Your order number. Unique per store. |
amountnumber | Order total in major units (12.5 = 12.500 OMR). |
currencystring | OMR · SAR · AED · KWD · QAR · BHD · USD |
status"paid" | "completed" | Current order status. |
customerobject | email (always send it — the only field used to find the Treez member), name, phone (optional, kept for your records, not used for matching). See Customer matching. |
occurredAtISO date, optional | When the order was placed. Defaults to now. |
Response
{
"id": "6f1c2a7e-0b7d-4c55-9a43-3f0d2a9b1e10",
"source": "api",
"externalOrderId": "1001",
"livemode": true,
"amount": 12.5,
"currency": "OMR",
"status": "paid",
"customer": {
"email": "salim@example.com",
"phone": "+96890000001",
"name": "Salim",
"trees": { "matched": true, "firstName": "Salim" }
},
"points": 125,
"pointsStatus": "pending",
"skipReason": null,
"confirmAt": "2026-11-07T09:30:00.000Z",
"confirmedAt": null,
"reversedAt": null,
"createdAt": "2026-10-24T09:30:00.000Z",
"updatedAt": "2026-10-24T09:30:00.000Z"
}Points = floor(amount × pointsPerUnit) for the order's currency, capped by your per-order maximum. No rule for the currency or below your minimum → points: 0 with skipReason.
Lifecycle
pendingPoints reserved from your budget; confirmAt = order time + hold days.
confirmedHold period over and the customer is on Treez → points credited.
reversedCancelled or refunded before confirmation → budget released.
Customer not on Treez yet? Points wait for up to 90 days and attach automatically when they sign in to the Treez app with that email.
Update the status
/v1/orders/:externalOrderId/statuscurl -X POST https://api.treezplus.com/v1/orders/1001/status \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "status": "refunded" }'Statuses: completed, cancelled, refunded. A refund after confirmation is recorded, but points are not taken back.
List & fetch
/v1/orders?pointsStatus&source&limit&cursor/v1/orders/:idcurl "https://api.treezplus.com/v1/orders?pointsStatus=pending&limit=20" \
-H "Authorization: Bearer sk_live_…"Customer matching#
Points go to the Treez account whose email matches the order's customer.email. Customers and planters sign in to the Treez app with a one-time code sent to their email, so the email is the identity we trust. Always send customer.email.
customer.emailstring · required for points | Matched case-insensitively (trimmed and lower-cased) against customer and planter accounts. |
customer.phonestring · optional | Stored and shown to you in the portal. Never used for matching — phone numbers on Treez aren't verified. |
customer.namestring · optional | Shown in your portal. |
customer.email is recorded with pointsStatus: "skipped" and skipReason: "NO_CUSTOMER_CONTACT" — those points could never be claimed, so nothing is reserved from your budget. Sending only a phone number counts as no email.Not on Treez yet
If no account has that email, the response shows trees.matched: false and the points wait as unclaimed for up to 90 days. They attach the moment someone signs in to the Treez app with that email (and confirm right away if the hold period is over). Otherwise they expire as unclaimed_expired and go back to your budget. Test-mode orders are never matched to real customers.
Skip reasons
| skipReason | Meaning |
|---|---|
NO_CUSTOMER_CONTACT | No customer email on the order, so there's no one to give points to. |
NO_RULE_FOR_CURRENCY | No earning rule for this currency. |
BELOW_MINIMUM | Below your minimum order amount. |
ZERO_POINTS | The order amount was too small to earn a whole point. |
INSUFFICIENT_BUDGET | Not enough points budget when the order arrived. |
AWAITING_STATUS | Waiting for the order to be completed (your earning rule awards on completion). |
CANCELLED_BEFORE_AWARD | Cancelled before it earned points. |
MERCHANT_NOT_ACTIVE | Your store wasn't approved yet when the order arrived. |
Idempotency#
Networks fail. Send an Idempotency-Key header (for example order-1001) and retry safely: the same key returns the original response with Idempotent-Replayed: true, and never creates points twice. Reusing a key with a different body returns 409 IDEMPOTENCY_KEY_REUSED.
Even without the header, orders are upserted by (store, source, externalOrderId).
Errors#
Errors use the HTTP status and a stable code:
{
"error": {
"code": "MERCHANT_NOT_ACTIVE",
"message": "Your store is not approved yet",
"details": {}
}
}| Code | HTTP | Meaning |
|---|---|---|
VALIDATION_ERROR | 400 | A field is missing or invalid — see details.issues. |
INVALID_API_KEY | 401 | The key is wrong, revoked or missing. |
UNAUTHORIZED | 401 | No credentials were sent. |
MERCHANT_NOT_ACTIVE | 403 | Your store isn't approved yet (live keys and real points). |
TEST_MODE_UNSUPPORTED | 403 | A test key called an endpoint that only works live. |
ORDER_NOT_FOUND | 404 | No order with that id for your store. |
IDEMPOTENCY_KEY_REUSED | 409 | Same Idempotency-Key, different request body. |
INSUFFICIENT_BUDGET | 409 | Not enough points budget (the order is kept as skipped). |
RATE_LIMITED | 429 | Too many requests — wait for Retry-After seconds. |
INTERNAL | 500 | Something broke on our side. Retry with the same Idempotency-Key. |
Webhooks#
Add an endpoint in Developers → Webhooks and Treez will POST JSON events to it — from orders to a tree going into the ground. Every event uses the same envelope:
{
"id": "evt_01JB2Q8M4K7Y3ZP6",
"type": "tree.planted",
"createdAt": "2026-10-24T09:30:00.000Z",
"livemode": true,
"merchantId": "b2c1…",
"data": {
"tree": { "id": "…", "status": "planted", "species": { "nameEn": "Ghaf" }, "…": "…" },
"passportUrl": "https://treez.om/t/ghaf-7k4p",
"fundingShare": 0.35
}
}Delivery & retries
- Answer with any 2xx within 10 seconds. Do slow work in a background job.
- Failed deliveries are retried after 1m · 5m · 30m · 2h · 6h · 12h · 24h (7 attempts).
- An endpoint that fails every delivery for 3 days is disabled automatically. Re-enable it from the portal.
- Events can arrive more than once or out of order — de-duplicate on
id.
Event types
| Event | When |
|---|---|
order.points_pending | An order earned points that are now on hold. |
order.points_confirmed | The hold period ended and the points were credited. |
order.points_reversed | An order was refunded or cancelled before confirmation. |
order.points_skipped | An order earned no points (no customer email, no rule, minimum, or budget). |
order.refunded_after_confirmation | An order was refunded after its points were confirmed. |
tree.ready | A tree your customers funded is ready to plant. |
tree.scheduled | That tree has a planting date. |
tree.planted | It's in the ground, with photo and GPS. |
tree.verified | A reviewer verified the planting evidence. |
claim_code.claimed | A customer claimed one of your QR codes. |
award.created | You gave a customer points at the till. |
merchant.approved | Treez approved your store. |
budget.low | Your points budget is running low. |
ping | A test event you send from the portal. |
order.* events carry { order }; tree.* events carry { tree, passportUrl, fundingShare } and only go to stores that funded that tree.
Verify signatures#
Every request carries a signature header:
Treez-Signature: t=1761298200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd- Split the header on
,; readt(unix seconds) and everyv1. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as hex. - Accept if it equals any
v1(constant-time compare) andtis less than 5 minutes old.
v1=new,v1=old), so you can deploy the new secret without dropping events.import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.TREEZ_WEBHOOK_SECRET; // "whsec_…"
// Keep the raw body: the signature covers the exact bytes we sent.
app.post("/treez/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Treez-Signature") ?? "";
const parts = header.split(",").map((p) => p.trim().split("="));
const t = parts.find(([k]) => k === "t")?.[1];
const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
// Reject old timestamps (replay protection).
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(400).send("stale");
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(req.body).digest("hex");
const valid = signatures.some(
(s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
);
if (!valid) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
switch (event.type) {
case "order.points_confirmed":
// event.data.order.points were credited to the customer
break;
case "tree.planted":
// event.data.passportUrl — share it with your customer!
break;
}
res.sendStatus(200); // answer fast; do slow work in a queue
});Platforms#
Each platform is an adapter that turns its install flow and webhooks into the same normalised order — so points, hold periods, refunds and your webhooks work exactly the same everywhere.
Platform tokens are stored encrypted. Uninstalling the app on the platform disconnects it in Treez. Want another platform? Adding one is a single adapter on our side — tell us at hello@treez.om.
Test mode#
- Use a
sk_test_key: orders are stored withlivemode: false, appear in the portal with a TEST badge and fire webhooks — no budget or wallets change. - Press Send test ping on an endpoint to receive a signed
pingevent and see the response in the delivery log. - Test keys can't create claim codes or awards (
TEST_MODE_UNSUPPORTED). - Locally, tunnel your endpoint (e.g. with ngrok) — production endpoints must use HTTPS.
Sign up, grab a test key and send your first order in five minutes.