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.
https://app.do.ink/api/v1Access 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. |
/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 }
}
]
}
]
}/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"
}/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
skuorvariantId. Sending both, or neither, is a validation failure. Quantity is 1–999; up to 100 items per order. -
postcodemust be exactly 5 digits, andphoneis 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. |
/v1/orders
Catch-up pathThis 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
200and 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: falseand an order number startingSBX-. List, read and cancel work as usual, but only see sandbox orders. - Sandbox orders only move when you call
POST /v1/orders/{id}/simulatewith{ "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
webhookfield. 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.