Limited beta v1

Merchant API

Send orders from your own store straight into Do.ink production, read their status back, and get a signed webhook every time one moves. Seven endpoints, one key, a free sandbox, no SDK required.

Base URL
https://app.do.ink/api/v1
New here? Start with the integration guide. The whole flow in order, from showing products to tracking the parcel.

Access and authentication

The Merchant API is in limited beta. Access is granted per account by a Do.ink admin, and only to identity-verified (KYC level 1) accounts — until yours is switched on, every call returns API_DISABLED. Request access and tell us about the website you want to connect.

You get one live key per account, created at /platforms/api. It is shown once, at creation. Send it as a bearer token on every request:

Authorization: Bearer dk_live_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

Live keys start with dk_live_; sandbox keys start with dk_test_ — see Sandbox. Treat a live key like a password: it can spend your wallet.

Endpoints

All paths are relative to https://app.do.ink/api/v1 — shown here without the /api prefix for brevity.

Method Path What it does
GET /v1/products Your catalog: SKUs, live production cost, your Do.ink store prices, descriptions, size chart and image URLs.
POST /v1/orders/quote Price a basket. Creates nothing, charges nothing.
POST /v1/orders Create an order. Paid from your wallet; if the wallet is short it waits for you to pay it.
GET /v1/orders List orders by updatedAt, cursor-paged. The catch-up path after missed webhooks.
GET /v1/orders/{orderId} One order, by Do.ink id or by your own number prefixed ext:.
POST /v1/orders/{orderId}/cancel Cancel an order that has not been produced. Full credit returned.
POST /v1/orders/{orderId}/simulate Sandbox only (test key): move a test order to a new status and fire its webhook.
GET

/v1/products

Your whole catalog, with the identifiers an order references. Do.ink does not store your own product codes — map them to sku on your side, once, and keep the mapping.

productionCost is live from the catalog, in THB, and is what an order of that variant will actually be billed. sellingPrice is your own default retail price — it is informational here and has no bearing on what you are charged. Every money field is in currency (always THB).

Watch out: any active variant can be ordered, whatever its product's status — draft, paused or archived. The list includes draft and paused products. If your store should only sell finished products, call GET /v1/products?status=active (also accepts draft and paused; archived products are never listed).

Your Do.ink store prices

If you also sell on your Do.ink store, the top-level store says whether it is selling (status: "live"; otherwise pending_kyc, frozen or off). Each product's store is its listing there: listed (your publish switch), stock (null = unlimited; otherwise one pool shared by all variants) and freeShipping. Each variant's store.price is what you set for it there, and store.salePrice is what a buyer pays right now after your best live store discount — the same number the store page shows. Prices are THB, VAT included, and are returned even before your store goes live so you can check them. Links (url) are given only when they work: the store's while it is live, a product's while it is also listed — otherwise null. Anything not on your store is null. Read-only: change prices at /platforms/easystore.

Images

images is the product's gallery in the order you arranged it in Do.ink, at most 8. Every URL is public — put it straight into an <img> tag or import it into your store; no key is needed. source: "generated" is your design on the garment (JPEG, rendered once and then served from cache); "upload" is a photo you uploaded yourself. Each variant's imageUrl is the first generated image in its colour, or null if its colour has none in the gallery.

A generated image URL changes when the picture changes (new design, moved print area). An old URL keeps working and shows the current picture, but re-sync your catalog to cache the new one.

Description and size chart

description is your own product text. productType describes the blank garment: details (fabric, fit, care) as Markdown, and sizeChartUrl, an image of the measurements for every size. Measurements are only published as that image, not as per-size numbers.

{
  "store": { "status": "live", "url": "https://app.do.ink/stores/studio" },
  "products": [
    {
      "productId": "3f6c1b7e-...",
      "name": "Studio Tee",
      "description": "Our everyday crew-neck tee.",
      "status": "active",
      "currency": "THB",
      "productType": {
        "name": "Classic T-Shirt",
        "description": "Cotton 100%, 180 gsm",
        "details": "**Fit:** regular\n\n**Care:** machine wash cold",
        "sizeChartUrl": "https://....blob.vercel-storage.com/size-chart.png"
      },
      "images": [
        {
          "url": "https://app.do.ink/api/product-images/3f6c1b7e-.../b81e.../5c0a9e2f41d7/Qm9...jpg",
          "source": "generated",
          "color": "Black",
          "side": "front"
        },
        {
          "url": "https://....blob.vercel-storage.com/custom-previews/lifestyle.jpg",
          "source": "upload",
          "color": null,
          "side": null
        }
      ],
      "store": {
        "listed": true,
        "url": "https://app.do.ink/stores/studio/products/3f6c1b7e-...",
        "stock": { "cap": 100, "available": 37 },
        "freeShipping": false
      },
      "variants": [
        {
          "variantId": "9a2d44c1-...",
          "sku": "DK-TEE-BLK-M",
          "color": "Black",
          "size": "M",
          "productionCost": 180,
          "sellingPrice": 450,
          "active": true,
          "imageUrl": "https://app.do.ink/api/product-images/3f6c1b7e-.../b81e.../5c0a9e2f41d7/Qm9...jpg",
          "store": { "price": 450, "salePrice": 405 }
        }
      ]
    }
  ]
}
POST

/v1/orders/quote

Price a basket without creating anything and without charging anything. The body is exactly the create body minus externalOrderNumber and expectedTotal. Use it to check your integration builds a valid order, and to show a cost to your own operator before they commit.

{
  "items": [
    { "sku": "DK-TEE-BLK-M", "quantity": 2, "productionCost": 360 }
  ],
  "charged": {
    "production": 360,
    "shipping": 50,
    "discount": 0,
    "total": 410
  },
  "currency": "THB"
}
POST

/v1/orders

Creates the order and pays it from your Do.ink wallet in the same call. Returns 201 with the created order, or 200 when the call was an idempotent replay and nothing new was created.

When your wallet is short

Your wallet is the only thing charged automatically — a saved card is never charged for an API order, because card payments need the bank's one-time code (3DS), which only you can enter. If the wallet does not cover the order, it is still created, with status awaiting_payment and a payment block. Nothing is made until it is paid. Open payment.payUrl in Do.ink and pay with credit (after a top-up) or a card. We also email you. When it is paid you get order.accepted, the same event a wallet-paid order sends at creation. payment is null when the order is paid. An unpaid order can be cancelled like any other.

HTTP/1.1 201 Created

{
  "payment": {
    "amountDue": 410,
    "balance": 120,
    "currency": "THB",
    "payUrl": "https://app.do.ink/orders/7f3a...?pay=1"
  },
  "order": {
    "status": "awaiting_payment",
    "timestamps": { "paidAt": null, … },
    …
  }
}

Request rules

  • Each item takes exactly one of sku or variantId. Sending both, or neither, is a validation failure. Quantity is 1–999; up to 100 items per order.
  • postcode must be exactly 5 digits, and phone is validated as a Thai number. Nobody eyeballs the address before it is printed on a label, so a typo is refused at the door rather than discovered by a courier.
  • Required: externalOrderNumber, items, and on the recipient — name, phone, line1, district, province, postcode. Optional: email, line2, subdistrict, productionNotes (max 500 characters), couponCode, expectedTotal.

Request

curl -X POST https://app.do.ink/api/v1/orders \
  -H "Authorization: Bearer dk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "externalOrderNumber": "WC-1042",
    "items": [
      { "sku": "DK-TEE-BLK-M", "quantity": 2 }
    ],
    "recipient": {
      "name": "สมชาย ใจดี",
      "phone": "0812345678",
      "email": "somchai@example.com",
      "line1": "123/45 ซอยสุขุมวิท 31",
      "line2": "ชั้น 4",
      "subdistrict": "คลองตันเหนือ",
      "district": "วัฒนา",
      "province": "กรุงเทพมหานคร",
      "postcode": "10110"
    },
    "productionNotes": "Fold and bag separately",
    "couponCode": null,
    "expectedTotal": 410
  }'

Response

HTTP/1.1 201 Created

{
  "payment": null,
  "order": {
    "livemode": true,
    "orderNumber": "DK-250918-0031",
    "externalOrderNumber": "WC-1042",
    "createdVia": "api",
    "source": "manual",
    "status": "accepted",
    "cancellationReason": null,
    "tracking": { "number": null, "carrier": null },
    "charged": {
      "production": 360,
      "shipping": 50,
      "discount": 0,
      "total": 410,
      "currency": "THB"
    },
    "items": [
      {
        "sku": "DK-TEE-BLK-M",
        "color": "Black",
        "size": "M",
        "quantity": 2,
        "productName": "Studio Tee"
      }
    ],
    "productionNotes": "Fold and bag separately",
    "timestamps": {
      "createdAt": "2026-09-18T04:11:52.000Z",
      "updatedAt": "2026-09-18T04:11:52.000Z",
      "paidAt": "2026-09-18T04:11:52.000Z",
      "shippedAt": null,
      "deliveredAt": null
    }
  }
}

The order object

Every endpoint that returns an order — create, get, list, cancel, and the webhook payload — returns exactly this shape. Money is in THB.

Field Type Meaning
livemode boolean false for a sandbox order made with a dk_test_ key — never charged, never produced.
orderNumber string Do.ink's own order number. Quote this to support.
externalOrderNumber string | null The number you sent. Null for orders you created inside Do.ink.
createdVia "api" | "app" Whether this order came through this API or was made in the Do.ink app.
source string Sales channel. API-created orders are always "manual"; orders from your Do.ink store or Store Embed are "easystore".
status enum One of the seven public statuses. See the status vocabulary below.
cancellationReason "merchant" | "rejected" | null Non-null only when status is cancelled.
tracking.number string | null Set once the parcel ships.
tracking.carrier string | null Carrier name, when recorded.
charged.production number What production cost you, in THB. Blank product plus print.
charged.shipping number Shipping charged to you, in THB.
charged.discount number Every discount leg you did not have to pay, summed.
charged.total number production + shipping − discount. The amount taken from your wallet. On an "easystore" order it is what the sale cost you (the buyer paid shipping; charged.shipping is only free shipping you fund).
charged.currency "THB" Always THB.
items[].sku string The variant SKU you ordered.
items[].color string | null Variant colour.
items[].size string | null Variant size.
items[].quantity number Units of this variant.
items[].productName string | null Product name, frozen at the time of the order.
productionNotes string | null The note you sent with the order, if any.
store object | null "easystore" orders only, else null: store.buyerTotal (what the buyer paid, items + shipping), store.profit (your profit after production and fees; 0 once reversed), store.reversed (true when the buyer was refunded — cancelled, rejected or refunded), store.currency.
embedRef string | null The data-doink-ref your website passed to the Store Embed, as given. Forgeable — a matching hint, not proof of identity.
timestamps.createdAt ISO 8601 When the order was created.
timestamps.updatedAt ISO 8601 Last change. This is the field GET /v1/orders sorts and filters on.
timestamps.paidAt ISO 8601 | null When the wallet was charged.
timestamps.shippedAt ISO 8601 | null When the parcel left.
timestamps.deliveredAt ISO 8601 | null When it arrived.

Status vocabulary

There are exactly seven statuses, and a new one is never added quietly — it is announced on this page first. awaiting_payment was added in September 2026, when orders the wallet cannot cover started being created instead of refused. Do.ink's internal production states are an operational detail that changes; these seven are the contract your code is written against.

Status Meaning
awaiting_payment Created, but your wallet did not cover it. Nothing is made until you pay it in Do.ink (credit or card).
accepted We have your order and it is paid. Not in production yet.
in_production Being printed and made.
shipped Handed to the carrier. Tracking is populated.
delivered Arrived with your customer.
cancelled Never produced. The money never left — your credit was released in full.
refunded It was produced and shipped, and then the money was returned to you.

cancelled and refunded are not the same event. cancelled means the order was never produced — the money never left your wallet. refunded means it was produced and shipped, and then the money was returned. If you post to a ledger, they need different entries.

cancellationReason

Non-null only when status is cancelled.

Reason Meaning
merchant You cancelled it, in the app or through this API.
rejected Do.ink declined to produce it.
GET

/v1/orders

Catch-up path

This is how you recover from missed webhooks — build it before you go live. Store the highest timestamps.updatedAt you have successfully processed. Whenever your endpoint has been down, or you are not sure, poll this endpoint with that value as updatedSince and follow the cursor until hasMore is false. Nothing can be lost, because results are ordered by updatedAt ascending.

Query parameter Meaning
updatedSince ISO 8601 timestamp. Only orders changed at or after this moment.
status One of the seven public statuses.
createdVia api or app. Use api to see only the orders your integration created.
cursor The nextCursor from the previous page.
limit 1–100. Defaults to 50.
GET /api/v1/orders?updatedSince=2026-09-18T00:00:00Z&limit=100

{
  "orders": [ /* … order objects, oldest updatedAt first … */ ],
  "nextCursor": "9a2d44c1-...",
  "hasMore": true
}

Reading one order

GET /v1/orders/{id} takes either Do.ink's order id, or your own number prefixed with ext: — for example ext:WC-1042. That means your system never has to store our identifier to look an order up.

Cancelling

POST /v1/orders/{id}/cancel takes the same identifier and returns the updated order. It works while the order has not been produced; once it has, you get ORDER_NOT_CANCELLABLE. The credit is returned to your wallet in full.

Idempotency and fixing mistakes

externalOrderNumber is required, and it is your idempotency key. It must be unique among your live orders.

  • Resending an identical body under the same number returns the existing order with 200 and charges nothing. A timed-out request is safe to retry.
  • Resending the same number with a different body fails with 409 DUPLICATE_EXTERNAL_ORDER. We will not silently pick one of the two.
  • A number belonging to a cancelled, rejected or refunded order is free again and can be reused.

There is no way to edit an order. To fix a wrong address or a wrong variant, cancel the order and create it again under the same number. That reuse rule exists precisely so the correction keeps the identifier your own system already recorded.

Price protection with expectedTotal

expectedTotal is optional. Supply it — usually the charged.total you got back from a quote — and the create call will refuse to charge you a different amount. If the live total differs by more than ฿0.01, the call fails with 409 PRICE_CHANGED and nothing is created.

Omit it to accept whatever the current catalog price is. Catalog costs can change at any time, so if your own margins are tight, send it.

The API never returns customer details

No customer name, no phone number, no email address, no shipping address — on any endpoint, for any order, including the webhook payload. You send recipient details and you never read them back.

This is deliberate. A leaked key is worth a spending spree against your wallet, and your wallet has a floor — but a customer list cannot be un-leaked. Keep the customer record in your own store, matched by externalOrderNumber.

Do.ink's own costs — production cost margin, profit, internal money — are equally absent. You see what you were charged, which is the number you can reconcile.

Errors

Every failure, on every endpoint, has the same shape. Switch on code — it is a stable part of the API. message is for a human reading your logs and may be reworded. details is present on some codes and carries structured context you can act on.

{
  "error": {
    "code": "PRICE_CHANGED",
    "message": "This order now costs ฿430.00, not the ฿410.00 you expected. Nothing was created or charged.",
    "details": { "expected": 410, "actual": 430, "currency": "THB" }
  }
}
Code HTTP When
UNAUTHORIZED 401 Missing, malformed, or revoked API key.
API_DISABLED 403 API access is not enabled on this account. Contact support.
API_UNAVAILABLE 503 The API is temporarily switched off platform-wide. Retry later.
VALIDATION_FAILED 400 The body is not valid JSON, or a field failed validation. The message names the field.
VARIANT_NOT_FOUND 404 No variant matches that sku or variantId.
PRODUCT_UNAVAILABLE 409 The variant exists but cannot be ordered right now.
DUPLICATE_EXTERNAL_ORDER 409 That externalOrderNumber is already taken by a live order with a different body.
PRICE_CHANGED 409 expectedTotal did not match the live price. Nothing was created.
ORDER_NOT_FOUND 404 No such order on this account.
ORDER_NOT_CANCELLABLE 409 The order has gone too far to cancel — it is already in production or beyond.
INVALID_TRANSITION 409 Sandbox: simulate asked for a move a real order cannot make from its current status.
SANDBOX_ONLY 403 simulate was called with a live key. It only works with a dk_test_ key.
RATE_LIMITED 429 Too many requests. Slow down and retry.
INTERNAL_ERROR 500 Something broke on our side. Safe to retry a GET; for a create, check with GET /v1/orders/ext:<your number> before retrying.

Webhooks

Configure one endpoint at /platforms/api. It must be https and publicly routable. Webhooks fire for API-created orders, and — if you switch on Also send webhooks for your store orders there — for orders from your Do.ink store and Store Embed too (source: "easystore", with a store block and your embedRef). Orders made inside the Do.ink app or synced from marketplaces never send webhooks, and a guest checkout that is never paid is never announced.

You get one event per status change. An order moving through internal steps that all read as the same public status (for example two steps that are both accepted) sends one order.accepted, not two.

Events

Event When
order.accepted The order is paid and Do.ink has it. Sent once, at creation or when paid.
order.in_production Printing has started.
order.shipped Handed to the carrier; tracking is set.
order.delivered Arrived with your customer.
order.cancelled Never produced; your credit was released. See cancellationReason.
order.refunded Produced and shipped, then the money was returned to you.
account.low_balance Your wallet dropped below the alert amount you set. Body has account, not order.
test.ping Sent by the "Send test event" button. Body has no order.

Low balance

API orders are paid from your credit balance only — a saved card is never charged for them. When your balance drops below the alert amount you set at /platforms/api (default ฿1,000), you get account.low_balance once, plus an email and an in-app alert. It fires again only after a top-up has lifted the balance back over the amount. Top up before new orders start arriving as awaiting_payment.

{
  "id": "7c41e0d2-...",
  "event": "account.low_balance",
  "livemode": true,
  "sentAt": "2026-09-26T04:11:52.000Z",
  "account": { "balance": 860, "threshold": 1000, "currency": "THB" }
}

Payload and headers

POST https://your-shop.example.com/hooks/doink
Content-Type: application/json
User-Agent: Doink-Webhook/1
X-Doink-Event: order.shipped
X-Doink-Delivery: 1f0a9c33-...
X-Doink-Mode: live
X-Doink-Signature: t=1758168712,v1=4b1c...e9

{
  "id": "1f0a9c33-...",
  "event": "order.shipped",
  "livemode": true,
  "sentAt": "2026-09-18T04:11:52.000Z",
  "order": { /* … the same order object as the REST endpoints … */ }
}
Header Meaning
X-Doink-Signature t=<unix seconds>,v1=<hmac-sha256 hex>
X-Doink-Event The event name, the same value as in the body.
X-Doink-Delivery Stable id for this delivery, equal to id in the body. The same across retries and manual resends — your deduplication key. Your handler must be idempotent: a delivery we sent but could not record as delivered will be sent again.
X-Doink-Mode live or test. Same as livemode in the body.
User-Agent Doink-Webhook/1

Verifying a delivery

The signed material is "<t>.<raw body>". Compute HMAC-SHA256 over it with your endpoint secret and compare in constant time, then reject anything whose timestamp is older than 5 minutes.

Node (Express)

import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.DOINK_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300; // reject anything older than 5 minutes

function verify(header, rawBody) {
  const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');
  if (!match) return false;

  const timestamp = match[1];
  const received = match[2];

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (age > TOLERANCE_SECONDS) return false;

  // The signed material is "<t>.<raw body>" — the exact bytes we sent.
  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(timestamp + '.' + rawBody)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

const app = express();

// Raw body, not express.json(): re-serialising the JSON changes the bytes
// and the signature will never match.
app.post('/hooks/doink', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');

  if (!verify(req.get('X-Doink-Signature'), raw)) {
    return res.status(401).end();
  }

  const payload = JSON.parse(raw);
  // Respond 2xx fast, then do the work. Deliveries can repeat — dedupe on
  // payload.id (the same value as X-Doink-Delivery), which never changes
  // across our retries or a manual resend.
  res.status(200).end();

  handle(payload);
});

Python (Flask)

import hashlib, hmac, os, re, time
from flask import Flask, request, abort

SECRET = os.environ["DOINK_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300  # reject anything older than 5 minutes
SIGNATURE = re.compile(r"^t=(\d+),v1=([0-9a-f]{64})$")

def verify(header: str | None, raw_body: bytes) -> bool:
    match = SIGNATURE.match(header or "")
    if not match:
        return False
    timestamp, received = match.group(1), match.group(2)
    if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    # The signed material is b"<t>." + the raw body — the exact bytes we sent.
    expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)

app = Flask(__name__)

@app.post("/hooks/doink")
def doink_webhook():
    raw = request.get_data()  # raw bytes, never request.json re-serialised
    if not verify(request.headers.get("X-Doink-Signature"), raw):
        abort(401)
    payload = request.get_json()
    # Dedupe on payload["id"] (== X-Doink-Delivery): retries and resends repeat it.
    handle(payload)
    return "", 200

Retries

Delivery is attempted immediately. Anything but a 2xx answer (a redirect included) is a failure, and it is retried once an hour, up to 24 attempts in all. After that we stop and notify you. The delivery log at /platforms/api shows each delivery's request body and the start of your endpoint's response, and can resend it by hand. You are never stuck either way: GET /v1/orders?updatedSince= reconstructs anything you missed.

Rate limits

60 requests per minute per account, across all endpoints, shared by your live and test keys. Order creation additionally has its own budget of 20 orders per 15 minutes per account. Over it, POST /v1/orders returns 429 RATE_LIMITED and nothing is created or charged. Sandbox (test key) orders do not count against it.

Over the limit you get 429 RATE_LIMITED, with details.retryAfterMinutes telling you how long to wait. Back off rather than hammering.

Sandbox

Create a test key (dk_test_…) at /platforms/api. It uses the same base URL and the same endpoints as your live key, with your real catalog and real prices, so every validation error and every total is the one production will give you. What is different:

  • Orders are never charged and never printed, and no wallet balance is needed — so a sandbox order is never awaiting_payment. They do not appear in your Do.ink orders list.
  • Every sandbox order has livemode: false and an order number starting SBX-. List, read and cancel work as usual, but only see sandbox orders.
  • Sandbox orders only move when you call POST /v1/orders/{id}/simulate with { "status": "shipped", "trackingNumber": "TH123" }. Allowed moves follow production: accepted → in_production → shipped → delivered, cancelled (as a Do.ink rejection) before shipping, refunded after shipping.
  • Webhooks go to the test webhook URL set on the test key, never to your live endpoint. Each event is sent once, right away, and the result is returned in the response's webhook field. There are no retries and no delivery log.
  • Sandbox orders are deleted after 30 days.

Testing a receiver on your own computer: use Send a test event at /platforms/api. Pick any order.* event, enter any https URL (a tunnel to localhost works), and see the status code and body your endpoint answered. It is signed with your sandbox webhook secret (X-Doink-Mode: test), or your live one if you have no sandbox webhook URL. Nothing is recorded and no order is touched.

Before going live, switch your key to dk_live_…, set your live webhook URL, and check it with the Send test event button, which delivers a test.ping through the live signing and retry path.

Store Embed

Show your Do.ink store — or one product as a Buy Button — inside your own website. Buyers browse in the frame; paying always happens on do.ink in a new tab, so card details never touch your site. No API key is needed.

<div data-doink-store="YOUR_USERNAME"></div>
<script src="https://app.do.ink/embed/doink.js" async></script>
Attribute Meaning
data-doink-store Required. Your Do.ink store username.
data-doink-product Optional. A product id: shows that one product as a Buy Button instead of the whole store.
data-doink-ref Optional, up to 100 characters. Your own reference for the visitor (e.g. your user id). Saved on the order and returned as embedRef in the API and webhooks. Anyone can edit it in the page, so use it to match orders, never to grant access.

The script sizes each frame to its content. Site builders that render late can call window.DoinkEmbed.mount() again. If your site builder strips scripts, use a plain iframe instead (fixed height):

<iframe src="https://app.do.ink/embed/store/YOUR_USERNAME"
  style="width:100%;height:900px;border:0" title="Do.ink Store" loading="lazy"></iframe>

Register your domain first. The frame loads only on the websites you list in Do.ink → Store → Embed, up to 5, https only (www. is included automatically). Anywhere else the browser blocks the frame — the script then replaces it with a plain "Shop on <store> ↗" link after 15 seconds and logs the fix to the console. Check a website in the Embed tab tells you whether a given page may show the store. The page the buyer came from is recorded on the order for display only.

Matching orders to your own customers: add data-doink-ref="<your user id>" to the placeholder (or &ref= to a plain iframe URL). It is saved on the order and returned as embedRef in GET /v1/orders, and in webhooks once you switch on store-order webhooks. Anyone can change it in their browser, so treat it as a hint for matching — never as proof of who the buyer is.

Ready to connect your store?

Create your key and configure your webhook endpoint in one place.