Firebase quickstart
Drop-in pattern for stores running on Firebase / Firestore. A Cloud Function listens for new order documents and calls /labels/create, then writes the tracking number back to the order.
What you need
- A Firebase project with Cloud Functions enabled (the Blaze / pay-as-you-go plan — fetch calls outside Google's network require it).
- A shiponline API token from Settings → API tokens with the
write:shipmentsscope. - A Firestore collection holding orders that include a shipping address + parcel dimensions. The sample assumes
orders/<orderId>; rename to match your schema.
1. Stash the API token
Cloud Functions can't read process env vars set on the function itself in v2 — use Firebase's secret manager (Functions v2) or the legacy runtime config (Functions v1).
# Functions v2 (recommended)
firebase functions:secrets:set SHIPONLINE_TOKEN
# (prompts you to paste the token; stored encrypted at rest)
# Functions v1 (legacy)
firebase functions:config:set shiponline.token="shp_live_..."2. The Cloud Function
This sample triggers on a new orders/<orderId> document, quotes USPS Priority for the shipment, buys the cheapest rate, then writes the tracking number + label URL back to the order so your storefront UI can render them.
// functions/src/index.ts
import { defineSecret } from "firebase-functions/params";
import { onDocumentCreated } from "firebase-functions/v2/firestore";
import { logger } from "firebase-functions/v2";
const SHIPONLINE_TOKEN = defineSecret("SHIPONLINE_TOKEN");
// Your warehouse address - lift to Firestore /settings if it varies
// per location, or per-environment env var.
const SHIP_FROM = {
name: "Your Store",
street1: "123 Main St",
city: "San Francisco",
state: "CA",
zip: "94103",
country: "US",
};
export const createShippingLabel = onDocumentCreated(
{
document: "orders/{orderId}",
secrets: [SHIPONLINE_TOKEN],
region: "us-central1",
},
async (event) => {
const order = event.data?.data();
if (!order) {
logger.warn("createShippingLabel: empty snapshot");
return;
}
if (order.shippingLabelUrl) {
logger.info(`Order ${event.params.orderId} already has a label, skipping.`);
return;
}
const token = SHIPONLINE_TOKEN.value();
const orderId = event.params.orderId;
try {
// 1. Quote rates
const quoteRes = await fetch(`https://shiponline.app/api/v1/labels/quote`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
fromAddress: SHIP_FROM,
toAddress: order.shippingAddress,
parcel: {
lengthIn: order.parcel?.lengthIn ?? 10,
widthIn: order.parcel?.widthIn ?? 8,
heightIn: order.parcel?.heightIn ?? 4,
weightOz: order.parcel?.weightOz ?? 16,
},
serviceCategory: order.expedited ? "express" : "standard",
}),
});
if (!quoteRes.ok) {
logger.error(`Quote failed for order ${orderId}`, await quoteRes.text());
return;
}
const { rates } = await quoteRes.json();
if (!rates?.length) {
logger.error(`No rates returned for order ${orderId}`);
return;
}
const chosen = rates[0]; // cheapest
// 2. Buy the label
const buyRes = await fetch(`https://shiponline.app/api/v1/labels/create`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
rateId: chosen.rateId,
// CRITICAL: idempotencyKey must derive from the order id, not
// a fresh UUID. A retry of this function on the same order
// must converge to the same key so the second call returns
// the original label instead of double-charging you.
idempotencyKey: `order-${orderId}`,
}),
});
if (!buyRes.ok) {
logger.error(`Buy failed for order ${orderId}`, await buyRes.text());
return;
}
const label = await buyRes.json();
// 3. Write the label info back to the order. The shipment is
// still in "paying" state at this point - the carrier finishes
// asynchronously within ~5 seconds. The webhook subscription
// below picks up the final tracking number + label URL.
await event.data.ref.update({
shiponlineLabelId: label.id,
shipmentStatus: label.status,
});
logger.info(`Label ${label.id} initiated for order ${orderId}`);
} catch (err) {
logger.error(`createShippingLabel error for ${orderId}`, err);
}
},
);Idempotency is the load-bearing detail
idempotencyKey on /labels/create must be derived from the Firestore orderId, not a fresh UUID. Firestore triggers can re-fire on the same document if the function crashes or times out; a stable key ensures the second invocation returns the original label instead of creating a second one + double-charging your account.3. Subscribe to webhooks for completion
The buy returns status: "paying" — the actual tracking number + label PDF land 2-10 seconds later. Add a webhook handler that updates the order when the shipment.purchased event fires.
Subscribe at startup
curl -X POST https://shiponline.app/api/v1/webhooks \
-H "Authorization: Bearer shp_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://us-central1-your-project.cloudfunctions.net/shiponlineWebhook",
"events": ["shipment.purchased", "shipment.failed", "shipment.delivered"]
}'Save the returned signingSecret to Firebase Secret Manager as SHIPONLINE_WEBHOOK_SECRET; the handler below verifies every delivery against it.
The webhook receiver
// functions/src/index.ts (continued)
import { onRequest } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";
import * as admin from "firebase-admin";
import crypto from "node:crypto";
if (!admin.apps.length) admin.initializeApp();
const SHIPONLINE_WEBHOOK_SECRET = defineSecret("SHIPONLINE_WEBHOOK_SECRET");
export const shiponlineWebhook = onRequest(
{
secrets: [SHIPONLINE_WEBHOOK_SECRET],
region: "us-central1",
},
async (req, res) => {
const signature = req.header("X-Shiponline-Signature");
const timestamp = req.header("X-Shiponline-Timestamp");
if (!signature || !timestamp) {
res.status(401).send("missing signature");
return;
}
// Replay-window: reject events older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
res.status(401).send("stale");
return;
}
// Use the raw request body for HMAC; Express parsed the JSON for
// us so we have to re-stringify — works because shiponline.app's
// JSON serialization is stable.
const raw = req.rawBody?.toString() ?? JSON.stringify(req.body);
const expected = crypto
.createHmac("sha256", SHIPONLINE_WEBHOOK_SECRET.value())
.update(`${timestamp}.${raw}`)
.digest("hex");
if (
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
) {
res.status(401).send("bad signature");
return;
}
const event = req.body;
const label = event.data;
// Find the order via the labelId we stashed on the order doc.
const orderSnap = await admin
.firestore()
.collection("orders")
.where("shiponlineLabelId", "==", label.id)
.limit(1)
.get();
if (orderSnap.empty) {
res.status(200).send("no matching order, skipping");
return;
}
const orderRef = orderSnap.docs[0]?.ref;
if (!orderRef) {
res.status(200).send("no ref");
return;
}
if (event.type === "shipment.purchased") {
await orderRef.update({
shipmentStatus: "purchased",
trackingNumber: label.trackingNumber,
shippingLabelUrl: label.labelUrl,
labelPurchasedAt: admin.firestore.FieldValue.serverTimestamp(),
});
} else if (event.type === "shipment.delivered") {
await orderRef.update({
shipmentStatus: "delivered",
deliveredAt: admin.firestore.FieldValue.serverTimestamp(),
});
} else if (event.type === "shipment.failed") {
await orderRef.update({
shipmentStatus: "failed",
shipmentError: label.errorMessage ?? "Label purchase failed",
});
}
res.status(200).send("ok");
},
);4. Deploy + smoke-test
firebase deploy --only functions- Manually create a Firestore document at
orders/test-123with a shipping address + parcel dimensions. - Watch Cloud Functions logs:
firebase functions:log --only createShippingLabel. - The order doc should gain
shiponlineLabelIdwithin ~1 second + atrackingNumber+shippingLabelUrlwithin ~5 seconds (when the webhook fires).
Hardening notes
- Defaults on parcel dimensions. The sample falls back to 10×8×4 / 16oz if the order doesn't carry parcel info. In production, refuse the label entirely rather than guessing — surface an error on the order doc so a human picks the right box.
- Address verification. Add a
POST /api/v1/addresses/verifycall on customer checkout instead of at label-buy time, so the user has a chance to fix bad zips before they pay. - Refunds. When an order is cancelled in Firestore, fire a
POST /api/v1/labels/{id}/refundon the corresponding label to void it. Refund hits your shiponline credit balance and auto-applies to your next purchase. - Insurance. For high-value orders, pass
insurance: { declaredValueCents: order.totalCents }in the/labels/createrequest body. Our dynamic-tier insurance is competitively priced and covers up to $15k (with prior approval — email support to enable the high-value tier).