Connecting a store
A store connects to Pixonaut without its merchant copying a key. The store sends the merchant to the studio’s consent screen, a member approves, and the store exchanges the code it is sent back with for everything it needs: an integration key, a publishable key bound to its address, and a webhook endpoint at the store. The exchange follows OAuth’s authorization code flow with PKCE.
1. Make a PKCE pair
Before sending the merchant anywhere, make a verifier and its S256 challenge on your server, and keep the verifier with the merchant’s session:
import crypto from "node:crypto";
// 43 base64url characters, within RFC 7636's 43 to 128.
const VERIFIER_BYTES = 32;
// Keep `verifier` on your server; send `challenge` to /connect.
export function makePkcePair() {
const verifier = crypto
.randomBytes(VERIFIER_BYTES)
.toString("base64url");
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
2. Send the merchant to the consent screen
Send the merchant’s browser to the studio’s /connect page with these query
parameters:
| Parameter | Value |
|---|---|
client |
The software the store runs: woocommerce. |
site_url |
The store’s home address, such as https://shop.acme.example, with no query or fragment. |
redirect_uri |
Where the merchant is sent back: a page on the same origin as site_url. |
state |
A value of your own, returned untouched, to match the answer to the request. |
code_challenge |
The challenge from step 1. |
code_challenge_method |
S256, the only method taken. May be left out. |
site_name |
Optional: the store’s own name, used in the integration key’s name. |
environment |
Optional: sandbox to connect only to a workspace’s sandbox, live to connect only to the live workspace. Left out, the member picks. |
rest_url |
Optional: the store’s REST address, on the same origin as site_url, such as https://shop.acme.example/?rest_route=/ for a WordPress site with plain permalinks. The webhook endpoint is registered under it. Left out, it is <site_url>/wp-json/. |
GET /connect?client=woocommerce&site_url=https%3A%2F%2Fshop.acme.example&redirect_uri=https%3A%2F%2Fshop.acme.example%2Fwp-admin%2Fadmin.php%3Fpage%3Dpixonaut-connect&state=n_7f3a9c&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&site_name=Acme%20Shop
A production deployment takes only https addresses for site_url,
redirect_uri and rest_url.
The merchant signs in if they need to, picks the workspace to connect, and
sees what the store will be able to do. Only a member who may manage API keys
(api-keys:manage) can approve. A workspace with a sandbox is offered as
Live or Sandbox (see Sandbox versus live, below), unless the request
names an environment.
On approval, the merchant is sent back to redirect_uri with code and your
state added to its query. If they cancel, they are sent back with
error=access_denied and your state instead.
3. Exchange the code
Check that state is the one you sent, then exchange the code from your
server, with the verifier and the same redirect_uri. The exchange takes no
API key: the code and the verifier are the proof.
A code can be exchanged once, within 60 seconds of the approval.
POST /v1/connect/token
Content-Type: application/json
{
"code": "c_4f8a1d2e9b7c4a6f8e3d5b1a",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
"redirect_uri": "https://shop.acme.example/wp-admin/admin.php?page=pixonaut-connect"
}
The answer carries everything the store is given, each secret shown in this
response only. Store them all at once. memberKey links the member who
approved to the store: it is the key the store sends for the user who
connected it on Pixel’s routes and the MCP endpoint (see Member key under
Authentication).
{
"tenantId": "0b1c2d3e-4f50-4a6b-8c7d-000000000003",
"tenantName": "Acme",
"tenantSlug": "acme",
"environment": "live",
"reachable": true,
"connectionId": "sc_0b1c2d3e-4f50-4a6b-8c7d-000000000004",
"integrationKey": {
"id": "0b1c2d3e-4f50-4a6b-8c7d-000000000005",
"key": "ik_live_0123456789abcdef01234567",
"label": "WooCommerce · Acme Shop (shop.acme.example)"
},
"publishableKey": {
"id": "0b1c2d3e-4f50-4a6b-8c7d-000000000006",
"key": "pk_live_89abcdef0123456789abcdef",
"allowedOrigins": ["https://shop.acme.example"]
},
"webhook": {
"id": "0b1c2d3e-4f50-4a6b-8c7d-000000000007",
"url": "https://shop.acme.example/wp-json/pixonaut/v1/webhook",
"secret": "whsec_0123456789abcdef0123456789abcdef"
},
"memberKey": {
"key": "mk_live_fedcba9876543210fedcba98",
"memberId": "usr_0b1c2d3e4f504a6b8c7d0008",
"memberName": "Alex Merchant",
"memberEmail": "[email protected]"
}
}
environment says which side of the workspace the store joined, live or
sandbox, and tenantName names the workspace; a sandbox is named after its
live workspace.
Any attempt uses the code up, and every refusal is the same
400 invalid_grant: an unknown or expired code, a redirect_uri that is not
the one approved, or a verifier that does not match the challenge. Whatever
went wrong, start again from step 1.
Connecting a site that is already connected replaces its connection: what it
was given before is revoked, and the new answer is all that works. A site is
matched by its address as written, so a store that moves from http to
https, or into another folder, connects as a new site.
What the store is given
- The integration key (
ik_live_…) is for the store’s server. It acts for the workspace within what a store does: it reads templates, blanks and printshops, checks a print size, commits a customer’s design (from the proof token its page drew, with the order details and its own reference), manages and deletes designs, makes and reads prints, places orders, manages its own webhook endpoint, and catches up on events withGET /v1/events. It never manages keys, members or settings. It is named after the store, and the audit record names the store’s changes by that name. - The publishable key (
pk_live_…) is for the store’s pages, where the designer runs. It is bound to the store’s origin, and may take other hosts of the store’s own domain too, such as itswww.counterpart or a checkout on a host of its own (see Reading and changing the connection, below). - The webhook endpoint is registered at the store, under its REST
address, and receives the events the store may read, of the types it
narrows them to, signed with
webhook.secret(Receive webhooks).
The integration key reaches only what its own store made: the designs,
prints and orders made with a key its site was given (a design its pages
committed with the publishable key, or its hosted designer committed through
a link it handed out, included), and the share links it handed out. Another
store’s design, print, order or link answers 404 not_found, as one in
another workspace does, whether a path names it or a body does (a print of
its design, an order of its print), and the lists leave it out. Templates,
blanks and printshops are the workspace’s, and every store reads them.
The webhook endpoint and GET /v1/events carry the same: events about the
store’s own designs, prints and orders (every order.* event carries the
order’s externalRef), and about templates, blanks and printshops. Events
about another store’s work, or about inks, palettes, shapes, assets and
assistant runs, never reach it.
A store is its site. Reconnecting the same site, or connecting it again
after disconnecting, mints new keys, and the store keeps working with what
it made under the old ones; a request sent again with the same
Idempotency-Key is answered with what the first one made
(Placing an order safely). A site that moves
to another address, http to https or into another folder included, is a new
store and starts with nothing.
What the store then does with them, from a customer’s design to the order arriving, is in A connected store, from connect to delivery.
The store’s webhook endpoint
To the integration key, the webhook routes reach its own endpoint alone:
GET /v1/webhooks lists that one, any other id is 404 not_found, and it
cannot register another. It can read the endpoint’s deliveries, ping it,
redeliver to it, switch it off and on, and choose the event types it
receives, with PATCH /v1/webhooks/{id}. Either field may be left out; an
empty eventTypes receives every type:
{ "active": true, "eventTypes": ["design.committed", "order.status_changed"] }
The endpoint cannot be deleted on its own (409 connection_endpoint): it
goes with the store’s other credentials when the store is disconnected.
Where it is sent
The merchant’s approval may name a webhookUrl for the endpoint, and
PATCH /v1/webhooks/{id} with a url changes it later. A store connected to a
sandbox may send it to any origin, such as a tunnel to a shop on a developer’s
own machine; a live store must stay on its site’s origin. Anything else is
400 invalid_webhook_url.
Pixonaut calls the address to check it, each time a url is sent, the address
it already has included. A live store is refused one that does not answer. A
sandbox store takes it and reads reachable: false, on the endpoint and in
GET /v1/connect: no delivery is enqueued, redelivering is
409 endpoint_unreachable, and the store reads its events with
GET /v1/events. Once the address moves or answers, events from then on are
delivered; what was waiting for the old one is dropped and is read by pulling,
so no backlog of attempts is queued.
The check at sending that refuses private addresses is the same for every address, so a public tunnel works and a loopback address does not.
Reading and changing the connection
The store reads its own connection with its integration key,
GET /v1/connect: its name as a member may have renamed it, its address,
the workspace and its environment, its webhook endpoint and the origins its
publishable key takes. Every other kind of key is 403 forbidden.
The same read answers the workspace settings that change what the store’s customers and merchant get, as the studio’s settings show them at that moment, so a plugin can show them in its own settings screen or warn its merchant:
branding: what the hosted pages the store sends customers to are drawn in, the store’s brand (see below):brandIdnames it,logoUrlloads its logo for a while (read the connection again for a fresh address), andaccentColoris the#rrggbbthe pages draw, an ink’s screen colour when the accent follows one. The logo and the colour are null when none is set.proofWatermark: whether a newly rendered proof, the one a customer approves, carries a watermark.deliverOrdersTo: null when new orders ship to their customer, else the workspace’s saved address they ship to instead, as{ id, name }.printing:minPrintDpi,defaultBleedMmanddefaultSafeAreaMm, the defaults a template falls back to: the workspace’s own, else the product’s.assetTtlHours: how long a customer upload nothing holds is kept.defaultLanguage: the workspace’s language,enuntil it chooses one, which its hosted pages and the designer are shown in when a link or a mount names nolocale.
A store connected to a sandbox reads the sandbox’s settings. None of them can
be changed with the integration key: a member changes them in the studio,
and PATCH /v1/connect refuses a body naming any of them as
400 invalid_input. The store’s brand is the one exception.
{
"connectionId": "sc_0b1c2d3e-4f50-4a6b-8c7d-000000000004",
"name": "WooCommerce · Acme Shop (shop.acme.example)",
"siteUrl": "https://shop.acme.example",
"tenantId": "0b1c2d3e-4f50-4a6b-8c7d-000000000003",
"tenantName": "Acme",
"tenantSlug": "acme",
"environment": "live",
"reachable": true,
"webhookEndpointId": "wh_5f2a9c1d7e3b4a6f8c0d2e4a",
"webhook": {
"id": "wh_5f2a9c1d7e3b4a6f8c0d2e4a",
"url": "https://shop.acme.example/wp-json/pixonaut/v1/webhook",
"active": true,
"reachable": true
},
"allowedOrigins": ["https://shop.acme.example"],
"brandId": null,
"printFileForm": null,
"printFileTextTolerance": null,
"branding": {
"brandId": "br_3f6c1a2e-9b4d-4e7a-8c5f-000000000001",
"logoUrl": "https://api.pixonaut.example/l/eyJhdWQiOiJsb2dvIn0.c2lnbmF0dXJl",
"accentColor": "#1a73e8"
},
"brands": [
{
"id": "br_3f6c1a2e-9b4d-4e7a-8c5f-000000000001",
"name": "Acme",
"isDefault": true,
"logoUrl": "https://api.pixonaut.example/l/eyJhdWQiOiJsb2dvIn0.c2lnbmF0dXJl",
"accentColor": "#1a73e8"
},
{
"id": "br_3f6c1a2e-9b4d-4e7a-8c5f-000000000002",
"name": "Acme Pets",
"isDefault": false,
"logoUrl": null,
"accentColor": "#2e7d32"
}
],
"proofWatermark": false,
"deliverOrdersTo": null,
"printing": {
"minPrintDpi": 150,
"defaultBleedMm": 3,
"defaultSafeAreaMm": 3,
"printFileForm": "flat",
"printFileTextTolerance": { "value": 1, "unit": "px" }
},
"assetTtlHours": 168,
"defaultLanguage": "en"
}
A store whose pages also run on other hosts of its own domain, its
counterpart with or without www., a checkout or a headless storefront,
adds them to its publishable key without connecting again, with
PATCH /v1/connect:
{
"additionalOrigins": [
"https://www.shop.acme.example",
"https://checkout.acme.example"
]
}
The rule is the store’s own domain, and nothing beyond it:
- With the integration key, any host on the site’s registrable domain,
as the Public Suffix List draws it, with the
site’s scheme and port. A store at
https://example.commay addhttps://www.example.com,https://checkout.example.comorhttps://shop.eu.example.com. One athttps://shop.co.ukmay not addhttps://other.co.uk, and each site under a hosting suffix such asgithub.iois its own domain. A site at an IP address or at a host with no dot (localhost) may add nothing. - Anything else needs a member, who mints a publishable key for it on the
studio’s API Keys page. An origin on another domain, a wildcard, an
origin with a path, or another scheme or port is
400 invalid_origin, naming the origin, and nothing changes.
Each origin is written as a browser’s Origin header spells it,
https://checkout.acme.example, with no path or trailing slash. The key
takes up to 10 besides the site’s own; more is 400 too_many_origins, naming
the limit. An origin added this way admits the designer with the store’s
publishable key, and a designer link’s returnUrl may point to it. The
site’s own origin is always kept and listed first, an empty list takes the
others away, and connecting again starts from the site’s own origin.
Choosing the store’s brand
A workspace that sells under several names keeps a brand for each: a name, a
logo and an accent colour. Every hosted page a store’s key hands out, the
proof pages it emails, correction links and designer links, is drawn in the
store’s brand. A store with none of its own is drawn in the workspace’s
default, and GET /v1/connect answers brandId: null.
brands lists the workspace’s brands, the default first, each with its
id, name, isDefault, logoUrl and accentColor, so a plugin can offer
them in its own settings screen. The store chooses one with
PATCH /v1/connect, or goes back to the default with null:
{ "brandId": "br_3f6c1a2e-9b4d-4e7a-8c5f-000000000002" }
An id that is not one of the workspace’s brands is 404 brand_not_found,
and nothing changes. A member may choose the store’s brand in the studio too,
on the API Keys page, and every change is in the workspace’s audit log.
A link keeps the brand it was created in: a change applies to the links
created after it. A brand a store is drawn in cannot be deleted, and
connecting the same site again keeps its brand.
Listing and disconnecting
A store disconnects itself with its own integration key,
DELETE /v1/connect, which answers 204. Call it when the merchant
disconnects the store from its own settings, or before the store is removed.
Members see every connected store, with its address and who approved it, on the studio’s API Keys page, and disconnect one there.
Disconnecting revokes all three credentials at once, and every share link the store’s key handed out. None of them can be revoked alone: a store with no publishable key, or no endpoint, would be half connected.
Sandbox versus live
A workspace with a sandbox is offered on the consent screen as Live or
Sandbox, Live unless the member picks otherwise. A store that knows which
it wants sends environment=sandbox (or live) with its request, and the
member is offered only that side. Connect a staging store
to the sandbox. Everything it is given then belongs to the sandbox, and its
keys say so: ik_test_… and pk_test_… where a live store’s read _live_.
A test key works on the sandbox alone, and an order placed in a sandbox never
reaches a printshop. The exchange, the answer and every route are the same
for both.
Questions? Write to [email protected].