> ## Documentation Index
> Fetch the complete documentation index at: https://developers.tassyir.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Write offers and codes

> Create and run offers and their codes with write_offers, mint influencer codes in bulk, read redemptions, and ship an app-owned offer type with a declarative rule.

With `write_offers` an app runs offers of its own: an influencer platform that
mints a code per influencer, a spin-to-win wheel, a referral program, a
flash-sale scheduler, a "mix and match" the merchant can create from your
app. It creates an offer, adds codes to it, publishes, pauses and archives it,
and reads who used it. [Offers](/guides/offers) is the read side and the
description of an offer; read it first.

| Route | Does | Scope |
| - | - | - |
| `POST /offers` | Create an offer (always a draft) | `write_offers` |
| `PATCH /offers/{id}` | Change an offer | `write_offers` |
| `POST /offers/{id}/publish`, `/pause`, `/resume` | Put it live, stop it, start it again | `write_offers` |
| `DELETE /offers/{id}` | Delete it, or archive it if it was used | `write_offers` |
| `POST /offers/{id}/codes`, `POST /offers/{id}/codes/bulk` | Add one code, or up to 250 | `write_offers` |
| `POST /offers/{id}/codes/{codeId}/disable`, `/enable` | Stop a code, or start it again | `write_offers` |
| `GET /offers/{id}/codes` | List the codes of an offer you own | `read_offers` |
| `GET /offers/{id}/redemptions` | List the uses of an offer you own | `read_offers` |

`write_offers` implies `read_offers`, so you read every offer of the merchant
and the offers webhooks. It is a normal scope, and like any scope added after
an app was installed, an installation does not hold it until the merchant
accepts a version of your app that asks for it
([Scopes and consent](/guides/scopes-and-consent)). A call without it answers 403
`PERMISSION_DENIED`, with a `fix` that names the scope. An **online** token
also needs the person's own permission to create, change and delete offers.

No particular plan is needed, but the plan's limit on live offers (a Starter
merchant's three) applies to your offers exactly as to the merchant's: publish
and resume answer 403 `PLAN_LIMIT_REACHED` past it. And an offer prices an
order only on a business where Tassyir's order pricing is on, which is rolled
out business by business: until then an offer you create is stored, listed and
published, but takes nothing off an order.

## You write only the offers you own

An offer your app creates is **owned by your installation**; there is no field
to set that. The rule:

* **You change, and read the codes and redemptions of, only the offers your own
  installation created.** A write on the merchant's offer, or on another
  app's, is 403 `OFFER_NOT_OWNED`, and so is reading its codes or redemptions.
  The `fix` says what to do: create your own offer and change that one.
* An offer the token cannot see (an id that does not exist or is malformed,
  another business's, a store outside the installation's stores) is 404
  `NOT_FOUND` on every route, writes included.
* **Reads stay as they are.** You list and read every offer your token can
  see. `appOwned` is true for any app's offer, so it does not say "yours":
  keep the ids of the offers you create, and a 403 tells you the rest.
* **The merchant keeps control.** The dashboard writes every offer, an app's
  included: pause it, archive it, add codes. You hear about it with
  `offers/updated`; that is not an error of yours.
* Uninstalling leaves your offers as they are. A new installation of the same
  app does not own them: the merchant manages them in the dashboard.
* A store-limited installation creates offers in its own stores only: a
  `storeId` outside them is HTTP 460 `STORE_SCOPE_FORBIDDEN`
  ([Admin API conventions](/guides/admin-api#stores-460-and-404)).

## Create an offer

An offer is created as a **draft**, then published. `config` is the offer's
rule **in the shape the Offers page stores it**, which is not the shape
`GET /offers` answers (money is a number of whole dinars, a quantity tier
carries `price`). Every field is checked by the same rules as the dashboard.

| Field | Rule |
| - | - |
| `storeId` | **Required.** The store the offer belongs to: an offer has exactly one. A store that is not the business's is 404 `OFFER_STORE_NOT_FOUND` |
| `kind`, `method` | **Required, and never changed.** `kind`: `quantity`, `free_delivery`, `product_discount`, `order_discount`, `bxgy`, `addon` or `app`. `method`: `automatic`, or `code` (the buyer types one of the offer's codes) |
| `title` | **Required.** The merchant's own name for the offer, 1 to 100 characters |
| `label` | What the buyer reads, `{ "ar": "…", "fr": "…", "en": "…" }`, each at most 120 characters |
| `config` | The kind's settings, below |
| `combinesWith`, `eligibility` | Merged into the kind's defaults, key by key ([the offer](/guides/offers#the-offer)) |
| `totalUsageLimit`, `perBuyerLimit` | Whole numbers from 1, or `null` for none. `perBuyerLimit` counts per phone number and may not exceed `totalUsageLimit` |
| `startsAt`, `endsAt` | An ISO date-time, or `YYYY-MM-DD`: a business day in Africa/Algiers (the first and the last instant of that day) |
| `targets` | `[{ role?, refType, refId? }]`: what the offer applies to. `refType` is `all`, `product`, `variant` or `collection`, and each must belong to the offer's store. Which roles a kind takes is below |
| `codes` | `method: "code"` only: up to 250 `{ code, usageLimit? }` added with the offer |

`config` per kind (money is whole dinars; an unknown key is `OFFER_INVALID`
with `UNKNOWN_FIELD`):

| `kind` | `config` | Targets |
| - | - | - |
| `quantity` | `{ priceMode, tiers: [{ pieces, price, label, preselected, freeDelivery }] }`; `priceMode` `total`, `percent` or `amount_each` | `product` or `variant`, role `buy` |
| `free_delivery` | `{ threshold: { kind: "pieces" \| "amount", value }, delivery: "home" \| "desk" \| "both", wilayas }` | none (store-wide) |
| `product_discount` | `{ valueType: "percent" \| "fixed", value, maxAmount?, showCountdown? }` | `all` alone, or products, variants and collections |
| `order_discount` | `{ valueType: "percent" \| "fixed", value, maxAmount?, minimum: { kind: "pieces" \| "amount", value } \| null }` | none |
| `bxgy` | `{ buy: { pieces }, get: { pieces, discount }, maxPerOrder }`: buy 2, get 1 free | `buy` (required to publish) and `get` |
| `addon` | `{ price: { type, value }, maxQuantity, beforeSubmit, afterSubmit }`: a product offered next to the order | `addon` (1 to 5 products or variants, required) and `buy` |
| `app` | `{ typeHandle, rule }`: an offer of one of your own types, [below](#your-own-offer-types) | none (the rule names its lines) |

You read an offer back in the block of its kind (`quantity`, `freeDelivery`,
`productDiscount`, `orderDiscount`, `bxgy`, `addon` or, for an app's own kind,
`appRule`), in the shape [Offers](/guides/offers#the-kinds) describes.

A **draft** is saved as long as everything present is well formed; **publish**
also checks completeness (a title, the kind's required settings, at least one
target where the kind needs one, at least one enabled code for a `code`
offer, an `endsAt` not in the past). A refusal is 400 `OFFER_INVALID` with
`errors`, a list of `{ field, code }` where `field` is a dotted path
(`config.tiers.1.pieces`) and `code` says why (`REQUIRED`, `INVALID_VALUE`,
`UNKNOWN_FIELD`, `TOO_LARGE`, `NOT_WHOLE_DINARS`, `NOT_ASCENDING`,
`ALL_IS_EXCLUSIVE`, …). Act on `code`, show `field` to your user.

## Influencer codes, in bulk

The classic: one offer, one code per influencer, each with its own usage
limit. Codes are **unique across the business**, whatever the case (`SARA10`
and `sara10` are one) and whether disabled or not, are 3 to 30 letters,
digits, `-` or `_`, and are never reused: disabling keeps a code's spelling
and its history.

```ts theme={null}
import { addOfferCodes } from '@tassyir/app';

const influencers = ['SARA10', 'OMAR10', 'LINA10'];

// 1. A draft: 10% off everything, only with a code, until the end of the year.
const { data: offer } = await admin.post('/offers', {
  body: {
    storeId: '741000000000000100',
    kind: 'product_discount',
    method: 'code',
    title: 'Autumn influencers',
    label: { ar: 'خصم المؤثرين' },
    config: { valueType: 'percent', value: 10 },
    targets: [{ refType: 'all' }],
    perBuyerLimit: 1,
    endsAt: '2026-12-31',
  },
});

// 2. One code each. 250 per call is the API's limit: addOfferCodes cuts the
// list into calls of 250 and gathers the answers in the order you sent.
const added = await addOfferCodes(
  admin,
  offer.id,
  influencers.map((code) => ({ code, usageLimit: 50 })),
);

// The call is partial by design: a code that cannot be created is reported on
// its own entry and the others are created.
const refused = added.results.filter((entry) => entry.outcome !== 'created');
for (const entry of refused) console.warn(entry.code, entry.outcome);

// 3. Put it live (at least one enabled code is required for a code offer).
await admin.post('/offers/{id}/publish', { path: { id: offer.id } });
```

Each entry of a bulk answer's `results` has an `outcome`:

| `outcome` | Means |
| - | - |
| `created` | The code exists now |
| `taken` | The business already has this code (on any offer, disabled or not) |
| `invalid` | Not 3 to 30 letters, digits, `-` or `_` |
| `duplicate_in_request` | The same code twice in this call |

`POST /offers/{id}/codes/bulk` takes 1 to 250 codes (more is 400
`VALIDATION_FAILED` and nothing is written) and answers 200 whatever the mix.
A single `POST /offers/{id}/codes` answers 201, or 409 `OFFER_CODE_TAKEN`,
`OFFER_NOT_CODE_METHOD` (an automatic offer has no codes) or 400
`OFFER_CODE_INVALID`. Disable and enable are idempotent; `GET
/offers/{id}/codes` lists the codes newest first, disabled ones included:

```json theme={null}
{
  "id": "741000000000000300",
  "code": "SARA10",
  "usageLimit": 50,
  "uses": 12,
  "disabled": false,
  "disabledAt": null,
  "createdAt": "2026-10-02T10:00:00.000Z"
}
```

`uses` counts the redemptions that still count against `usageLimit` (held or
final): a cancelled order gives its use back. Codes appear nowhere else: the
offer, the webhooks and an order show a count, or nothing.

## Publish, pause, resume, delete

`publish`, `pause` and `resume` are **idempotent**: asking for the state the
offer is already in changes nothing and sends no event. Their body is
optional: send none, or `{ expectedVersion }` (below).

| Route | Does | Refused with |
| - | - | - |
| `publish` | Puts a draft live from its start date. An incomplete offer is 400 `OFFER_INVALID` | 403 `PLAN_LIMIT_REACHED`, 409 `OFFER_ARCHIVED` |
| `pause` | Stops a live offer pricing orders | 409 `OFFER_NOT_PUBLISHED` (a draft), `OFFER_ARCHIVED` |
| `resume` | Starts a paused offer again. An ended offer is not resumed: move its `endsAt` | 403 `PLAN_LIMIT_REACHED`, 409 `OFFER_NOT_PUBLISHED`, `OFFER_ENDED` |
| `DELETE` | An offer **nobody ever redeemed** is deleted with its codes and targets. One with any redemption is **archived** (status `ended`) | 403 `OFFER_NOT_OWNED` |

`DELETE` answers which it did, so you do not guess:

```json theme={null}
{ "id": "741000000000002000", "deleted": false, "archived": true }
```

`PATCH /offers/{id}` changes the fields you send, from the table above
(`kind` and `method` may be echoed back but never change: 400
`OFFER_KIND_IMMUTABLE`). `targets` and `config`, when sent, **replace** the
whole set; an empty body is 400. A live offer must stay publishable. Every
write on an offer can carry `expectedVersion`, the dashboard's compare and
set: a stale one is 409 `OFFER_VERSION_CONFLICT` with `currentVersion`. Without
it the change applies to the offer as it is when the request starts.

```ts theme={null}
const { data: changed } = await admin.patch('/offers/{id}', {
  path: { id: '741000000000002000' },
  body: { endsAt: '2027-01-15', totalUsageLimit: 1000 },
});
console.log(changed.status, changed.endsAt);
```

Writes are idempotent with an `Idempotency-Key`
([Admin API conventions](/guides/admin-api#idempotent-writes)); the typed client
sends one for you.

## Redemptions

`GET /offers/{id}/redemptions` is each use of an offer you own on an order,
newest first, in pages. `state` filters it: `held` (the order is not with a
carrier yet), `final` (handed over) or `released` (the order was cancelled, or
the buyer banned).

```ts theme={null}
// How many orders each code brought, counting the uses that stand.
const perCode = new Map<string, number>();
for await (const redemption of admin.paginate('/offers/{id}/redemptions', {
  path: { id: '741000000000002000' },
})) {
  if (redemption.state === 'released') continue;
  const code = redemption.code ?? 'automatic';
  perCode.set(code, (perCode.get(code) ?? 0) + 1);
}
console.log([...perCode]);
```

```json theme={null}
{
  "id": "741000000000000400",
  "orderId": "741000000000000401",
  "codeId": "741000000000000300",
  "code": "SARA10",
  "amount": "900.00",
  "currency": "DZD",
  "state": "final",
  "createdAt": "2026-10-04T10:00:00.000Z"
}
```

`amount` is what the offer took off that order, a decimal string. **Never a
buyer:** no name, phone or address. `orderId` is the one link: read the order
with `GET /orders/{id}` (`read_orders`, and `read_customer_contact` for the
buyer's contact fields, as for any order). An installation that owns the offer
but cannot read orders sees ids it cannot open. `codeId` and `code` are `null`
for an automatic offer, and `code` is `null` once the code was removed.

## Applying a code to an order

An app does not apply an offer to an order: orders get their offers from
Tassyir's pricing. When your app creates an order for a buyer who typed a
code, send it:

```ts theme={null}
const { data: order, rejectedCodes } = await admin.post('/orders', {
  body: {
    buyer: { name: 'Amina B.', phone: '+213555123456' },
    items: [{ productId: '741000000000000500', quantity: 2 }],
    codes: ['SARA10'],
  },
});

for (const entry of rejectedCodes ?? []) {
  console.log(`${entry.code} was not used: ${entry.reason}`);
}
console.log(order.discountTotal, order.discounts);
```

`codes` takes **one code at most** (case and surrounding spaces do not
matter). A code that does not apply never fails the order: the order is created
without it and `rejectedCodes` names the code with the reason (`code_unknown`,
`code_expired`, `code_used_up`, `not_eligible_audience`, `beaten_by`, …). The
order's `discounts[]` entry carries the code and `source: "code"`
([Order money](/guides/orders#order-money)). A usage limit that runs out between
the pricing and the write is 409 `OFFER_LIMIT_REACHED` and nothing is created.

## Webhooks

You hear your own writes, like any installation: the topics are
business-wide, so the app that created an offer receives `offers/created` for
it ([Offers](/guides/offers#webhooks)). A new offer is a draft, so `offers/updated`
with `changed` naming `status` and `publishedAt` is when it goes live; adding
or disabling a code sends `offers/updated` with `changed: ["codeCount"]`.
Deduplicate on `webhook-id`, and when two bodies of one offer arrive out of
order keep the one with the later `updatedAt`.

## Your own offer types

A native offer is one of the kinds above. An app can add **kinds of its own**
that the merchant creates from your app: "mix and match", a spin-to-win
prize, a loyalty tier. You declare each type in the manifest, and you write
its offers with `kind: "app"`. Pricing stays in Tassyir: an app-owned offer is
a **declarative rule**, JSON the pricing engine evaluates, so no code of yours
runs at checkout, it costs nothing at checkout and it can never fail an order.

Declare the types in `tassyir.app.toml` (at most 10; declaring any needs the
`write_offers` scope):

```toml theme={null}
name        = "Wheely"
handle      = "wheely"
api_version = "2026-10"

[access]
scopes = ["write_offers", "read_products"]

[access.scope_reasons]
write_offers  = "Create the offers your merchants set up in Wheely"
read_products = "Let a merchant pick the products an offer applies to"

[hosting]
mode = "external"
url  = "https://wheely.example.com"

[admin]
page = "/"

[[offers.types]]
handle      = "mix-match"
class       = "product"
path        = "/offers/mix-match"
label       = { ar = "اخلط واختر", en = "Mix and match" }
description = { ar = "القطعة الثالثة مجانًا", en = "The third piece free" }
```

| Field | Rule |
| - | - |
| `handle` | Required. 1 to 40 lowercase letters, digits and hyphens, starting with a letter or digit. Unique among your types |
| `class` | Required. `product`, `order` or `delivery`: the step the type's offers price in, with the stacking rules of a native offer of that class |
| `path` | Required. Your page that configures one offer of this type: a path on your origin (the `admin.page` rules, at most 200 characters) |
| `label` | Required. `{ ar, en }`, up to 50 characters each: what the merchant reads under "From your apps" |
| `description` | Optional. `{ ar, en }`, up to 200 characters each |

The merchant sees your types in the create dialog of the Offers page, **"From
your apps"**, for every active installation of your app that holds
`write_offers` (directly or implied) and may act on that store. Choosing one
opens your embedded page at the type's `path` ([Embedded pages](/guides/embedded-pages)),
which is where the offer is set up: your page creates the offer with the Admin
API, and later changes it.

```ts theme={null}
import type { AppOfferConfig } from '@tassyir/app';

// The third piece free on a collection, in two wilayas.
const config: AppOfferConfig = {
  typeHandle: 'mix-match',
  rule: {
    class: 'product',
    when: {
      all: [
        { 'lines.collection': '741000000000000900', 'quantity.gte': 3 },
        { 'delivery.wilaya.in': ['16', '31'] },
      ],
    },
    then: [
      { 'discount.cheapest': { percent: 100, count: 1 } },
      { label: { ar: 'القطعة الثالثة مجانًا', en: 'The third piece free' } },
    ],
  },
};

const { data: offer } = await admin.post('/offers', {
  body: {
    storeId: '741000000000000100',
    kind: 'app',
    method: 'automatic',
    title: 'Mix and match, autumn',
    config,
  },
});
```

`typeHandle` must be one of the `[[offers.types]]` of the version your
installation runs, else 400 `OFFER_INVALID` with `config.typeHandle:
NOT_DECLARED`. Every product, variant and collection the rule names must
exist in the offer's store (400 `OFFER_TARGET_NOT_FOUND` or
`OFFER_TARGET_OUTSIDE_STORE`).

* **The merchant manages the offer's life, you manage its settings.** On the
  Offers page an app-owned offer can be published, paused, resumed and
  archived (and its codes managed), but editing or duplicating it there
  answers 409 `OFFER_APP_OWNED`: it is changed in your app, at the type's
  `path`. Your own `PATCH` calls on your own offers are not affected.
* **It prices only while your installation is active and your app is not
  suspended.** Uninstalling, or a suspension, stops it pricing orders at once;
  the offer is kept, and reinstalling or resuming prices it again.
* An offer of a type is owned by the installation that created it, like any
  other offer of yours.

### The rule

A rule is `{ class, when?, then }`, validated on every write (400
`OFFER_INVALID`, with a coded issue per problem) and read again on every
price: a stored rule that no longer parses prices nothing, never fails an
order.

**`when`** is absent (always), a **leaf**, or `{ "all": [...] }` / `{ "any": [...] }` of 1 to 10 conditions, nested at most 3 deep (`TOO_DEEP`). A leaf is
an object with at least one of these keys (an empty one is `EMPTY`):

| Key | Tests |
| - | - |
| `lines.product`, `lines.variant`, `lines.collection` | Picks lines: an id, or 1 to 50. Every selector present in the leaf must match a line |
| `quantity.gte`, `quantity.lte` | Pieces, over the lines the leaf picked (the whole cart when it picked none) |
| `subtotal.gte`, `subtotal.lte` | Whole dinars, over the same lines |
| `delivery.wilaya.in` | Two-digit wilaya codes, `"01"` to `"69"` |
| `delivery.method` | `home` or `desk` |
| `channel.in` | Among `store`, `landing`, `shopify`, `dashboard`, `apps` |
| `audience` | `first_order` or `returning` |
| `time.between` | `{ "from": "18:00", "to": "23:59" }` on the Africa/Algiers clock; `from` after `to` spans midnight |

The lines a satisfied leaf picked are the lines the product actions apply to
(every priceable line when no leaf picked any).

**`then`** is 1 to 5 actions, each key once (`DUPLICATE`), at least one that
prices (`NO_ACTION`), all of the rule's `class` (`ACTION_CLASS_MISMATCH`):

| Class | Action | Value |
| - | - | - |
| `product` | `discount.lines` | `{ percent }` off each selected line, or `{ amount_each }` whole dinars off each piece |
| `product` | `discount.cheapest` | `{ percent, count }`: that percentage off the `count` cheapest selected pieces ("the third free") |
| `product` | `give.item` | `{ productId }` or `{ variantId }` (one of the two), `percent` (default 100), `count` (default 1): a gift |
| `order` | `discount.order` | `{ percent }` or `{ amount }` whole dinars off the goods |
| `delivery` | `delivery.waive` | `{}`: the quoted delivery fee is waived |
| any | `label` | `{ ar?, fr?, en? }` (120 characters each): buyer-facing text, prices nothing |

A **gift is never added by Tassyir**: when the rule is satisfied and the gift
is missing, the cart answers with a suggestion, the buyer's form adds the gift
as a visible, removable line, and the engine then discounts it.

`percent` is above 0 and at most 100; dinars are whole numbers. The issue
codes you can get in `errors[].code`, besides those of every offer
(`REQUIRED`, `INVALID_TYPE`, `INVALID_VALUE`, `TOO_LARGE`, …):

| Code | Means |
| - | - |
| `NOT_DECLARED` | `config.typeHandle` is not one of the `[[offers.types]]` of your installation's version |
| `UNKNOWN_FIELD` | A key the rule does not have (the rule is strict) |
| `EMPTY` | A leaf with no key |
| `TOO_DEEP` | `all` / `any` nested more than 3 deep |
| `DUPLICATE` | The same action key twice |
| `NO_ACTION` | Only a `label`: nothing to price |
| `INVALID` | A condition or an action that matches none of the shapes above |
| `ACTION_CLASS_MISMATCH` | An action that belongs to another class than the rule's |
| `ONE_OF_PRODUCT_OR_VARIANT` | `give.item` names both a product and a variant, or neither |

## Errors these routes add

Beyond the codes every route shares ([Errors](/guides/errors)):

| Code | Status | When |
| - | - | - |
| `OFFER_NOT_OWNED` | 403 | The offer is the merchant's or another app's (writes, codes, redemptions) |
| `OFFER_INVALID` | 400 | The offer does not pass the dashboard's validation: `errors` lists `{ field, code }` |
| `OFFER_KIND_NOT_AVAILABLE`, `OFFER_KIND_IMMUTABLE` | 400 | A kind not offered; a changed `kind` or `method` |
| `OFFER_TARGET_NOT_FOUND`, `OFFER_TARGET_OUTSIDE_STORE` | 400 | A target (or a product the rule names) that does not exist, or belongs to another store |
| `OFFER_CODE_INVALID` | 400 | A single code that is not 3 to 30 letters, digits, `-` or `_` |
| `OFFER_STORE_NOT_FOUND`, `OFFER_CODE_NOT_FOUND` | 404 | `storeId` is not a store of the business; `codeId` is not a code of this offer |
| `OFFER_VERSION_CONFLICT` | 409 | `expectedVersion` is stale: `currentVersion` says what it is |
| `OFFER_ARCHIVED`, `OFFER_NOT_PUBLISHED`, `OFFER_ENDED`, `OFFER_NOT_CODE_METHOD`, `OFFER_CODE_TAKEN` | 409 | The offer's state, or the code's uniqueness, does not allow the move |
| `PLAN_LIMIT_REACHED` | 403 | The plan's limit on live offers (publish, resume, an edit that makes the offer count) |
| `STORE_SCOPE_FORBIDDEN` | 460 | A store outside the installation's |

`OFFER_APP_OWNED` (409) is not one of these: it is what the **merchant's
dashboard** answers when someone tries to edit or duplicate an app-owned
offer, as described above. Your own calls never get it.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.