Skip to main content

Example: A Complete API Integration

This page puts the whole API integration flow into one small, runnable server: the install callback, the token exchange, token refresh, a signed webhook and an API call. It is written for Node.js 18 or newer and uses no packages, so every step stays visible. The same steps work in any language.

The example reacts to paid orders: when a webhook says an order was paid, it reads the order through the API and logs it. Swap the log line for your ERP, warehouse or CRM.

What you need​

  • An app with OAuth enabled in the Partner Portal, with its redirect URI set to https://your-app.example/sitepack/callback.
  • In app.json:
{
"auth": { "scopes": ["orders:read"] },
"webhooks": [
{ "topic": "order.paid", "address": "https://your-app.example/sitepack/webhooks" }
]
}
  • The three secrets from the OAuth & install card, as environment variables:
export SITEPACK_CLIENT_ID=...
export SITEPACK_CLIENT_SECRET=...
export SITEPACK_SIGNING_SECRET=...

The server​

server.mjs
import http from 'node:http';
import crypto from 'node:crypto';

const API = 'https://api.sitepack.eu';
const {SITEPACK_CLIENT_ID, SITEPACK_CLIENT_SECRET, SITEPACK_SIGNING_SECRET} = process.env;

// One entry per site that installed the app. A token is bound to one site, so an app
// that serves ten merchants holds ten tokens. Use a real database in production.
const installs = new Map(); // site uuid -> {accessToken, refreshToken, expiresAt}
const pendingStates = new Set();

// --- Signatures -------------------------------------------------------------------

// SitePack signs "{timestamp}.{payload}" with HMAC-SHA384 and sends "v1=<hex>".
function isSigned(payload, timestamp, signature, maxAgeSeconds = 300) {
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > maxAgeSeconds) return false;

const expected = 'v1=' + crypto
.createHmac('sha384', SITEPACK_SIGNING_SECRET)
.update(`${timestamp}.${payload}`)
.digest('hex');

const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// --- Tokens -----------------------------------------------------------------------

async function tokenRequest(fields) {
const response = await fetch(`${API}/api/authentication/oauth/token`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
client_id: SITEPACK_CLIENT_ID,
client_secret: SITEPACK_CLIENT_SECRET,
...fields,
}),
});
if (!response.ok) throw new Error(`Token request failed: ${response.status}`);

const token = await response.json();
return {
accessToken: token.access_token,
refreshToken: token.refresh_token,
// Refresh a minute early rather than racing the expiry.
expiresAt: Date.now() + (token.expires_in - 60) * 1000,
};
}

async function accessTokenFor(siteUuid) {
const install = installs.get(siteUuid);
if (!install) throw new Error(`Not installed on site ${siteUuid}`);

if (Date.now() >= install.expiresAt) {
const fresh = await tokenRequest({
grant_type: 'refresh_token',
refresh_token: install.refreshToken,
});
installs.set(siteUuid, fresh);
return fresh.accessToken;
}

return install.accessToken;
}

async function api(accessToken, path) {
const response = await fetch(`${API}/api/public/v1${path}`, {
headers: {'X-SitePack-Access-Token': accessToken},
});
const body = await response.json();
if (body.status !== 'success') {
// Switch on error.code, never on the message.
throw new Error(`${path}: ${body.error?.code} ${body.error?.message}`);
}
return body;
}

// --- Routes -----------------------------------------------------------------------

// Your own "Connect SitePack" button: the install link from the Partner Portal, with a
// fresh state per install.
function connect(res) {
const state = crypto.randomBytes(16).toString('hex');
pendingStates.add(state);

const url = new URL(`${API}/api/authentication/oauth/authorize`);
url.search = new URLSearchParams({
client_id: SITEPACK_CLIENT_ID,
response_type: 'code',
scope: 'orders:read',
redirect_uri: 'https://your-app.example/sitepack/callback',
state,
});

res.writeHead(302, {Location: url.toString()}).end();
}

async function callback(url, res) {
const q = url.searchParams;

if (q.get('error')) {
return res.writeHead(200).end('Installation was cancelled.');
}

// 1. state guards against CSRF. A merchant who installs from the Marketplace arrives
// with a state you did not issue; the signature below still proves the origin.
const state = q.get('state') ?? '';
pendingStates.delete(state);

// 2. The signature proves SitePack sent this redirect and nothing was changed.
if (!isSigned(`${q.get('code')}.${state}`, q.get('signed_at'), q.get('signature'))) {
return res.writeHead(400).end('Invalid signature');
}

// 3. Exchange the one-time code for tokens.
const tokens = await tokenRequest({grant_type: 'authorization_code', code: q.get('code')});

// 4. Ask which site this token belongs to, and store the tokens under it.
const me = await api(tokens.accessToken, '/me');
installs.set(me.site.uuid, tokens);
console.log(`Installed on ${me.site.name} with scopes: ${me.scopes}`);

// 5. Send the merchant back into SitePack, where your settings page lives.
res.writeHead(302, {Location: q.get('manage_url')}).end();
}

async function webhook(req, res) {
// Verify the raw body exactly as received: never re-serialize the JSON first.
let raw = '';
for await (const chunk of req) raw += chunk;

const valid = isSigned(
raw,
req.headers['x-sitepack-webhook-timestamp'],
req.headers['x-sitepack-webhook-signature'],
);
if (!valid) return res.writeHead(401).end();

// Answer fast; SitePack retries anything that is not a 2xx.
res.writeHead(200).end();

const event = JSON.parse(raw);
if (event.test_mode) return console.log('Test webhook received', event.topic);

if (event.topic === 'order.paid') {
// The payload is thin: it names the order, the API has the details.
const orderUuid = event.arguments.uuid;
const token = await accessTokenFor(event.site);
const {order} = await api(token, `/orders/${orderUuid}`);
console.log('Paid order', order?.uuid ?? orderUuid);
}
}

http.createServer(async (req, res) => {
const url = new URL(req.url, 'https://your-app.example');
try {
if (url.pathname === '/connect') return connect(res);
if (url.pathname === '/sitepack/callback') return await callback(url, res);
if (url.pathname === '/sitepack/webhooks' && req.method === 'POST') return await webhook(req, res);
res.writeHead(404).end();
} catch (error) {
console.error(error);
if (!res.headersSent) res.writeHead(500).end();
}
}).listen(3000, () => console.log('Listening on :3000'));

What each part does​

PartWhy
connectYour own install button. A merchant who installs from the Marketplace skips this and lands on the callback directly.
callbackVerifies the redirect, exchanges the code, finds out the site with GET /me, stores the tokens and sends the merchant back with manage_url.
accessTokenForAccess tokens last an hour. Refresh with the refresh token and store the new pair: the old refresh token stops working.
webhookVerifies the signature over the raw body, answers 200 straight away and does the work afterwards.
apiSends X-SitePack-Access-Token and treats any response without "status": "success" as an error.

Before you go live​

  • Store tokens in a database, encrypted, keyed on the site uuid. The in-memory Map above is lost on every restart.
  • Refresh at least once every 30 days. A refresh token expires after 30 days. For a site that rarely triggers a call, refresh on a schedule so the install does not silently lose access.
  • Handle 401 and 403. A 401 on a fresh token, or a failing refresh, means the merchant removed your app: delete their tokens. A 403 with error.code scope_required means you are calling something you did not ask for.
  • Process webhooks in a queue when the work takes more than a moment. SitePack retries a failed delivery once, and a retry gets a new event uuid, so make the handler safe to run twice for the same topic and arguments.uuid.
  • Mind the rate limit: 600 requests per minute per install. Every response carries X-SitePack-RateLimit-Tokens; on a 429, wait the number of seconds in Retry-After.
  • Try it with the Test button. Under App Webhooks on your app page in the Partner Portal, Test sends a signed message with "test_mode": true.

Next steps​