Skip to main content
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 is the read side and the description of an offer; read it first. 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). 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).

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. config per kind (money is whole dinars; an unknown key is OFFER_INVALID with UNKNOWN_FIELD): 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 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.
Each entry of a bulk answer’s results has an outcome: 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:
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). DELETE answers which it did, so you do not guess:
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.
Writes are idempotent with an Idempotency-Key (Admin API conventions); 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).
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:
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). 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). 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):
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), which is where the offer is set up: your page creates the offer with the Admin API, and later changes it.
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): 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): 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, …):

Errors these routes add

Beyond the codes every route shares (Errors): 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.