Integrations

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:shipments scope.
  • 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).

shell
# 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.

TypeScript · Functions v2
// 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

The 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

shell
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

TypeScript · Functions v2
// 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

  1. firebase deploy --only functions
  2. Manually create a Firestore document at orders/test-123 with a shipping address + parcel dimensions.
  3. Watch Cloud Functions logs: firebase functions:log --only createShippingLabel.
  4. The order doc should gain shiponlineLabelId within ~1 second + a trackingNumber + shippingLabelUrl within ~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 aPOST /api/v1/addresses/verify call 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}/refund on 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/create request body. Our dynamic-tier insurance is competitively priced and covers up to $15k (with prior approval — email support to enable the high-value tier).

What to read next

  • Labels — full Create / Get / List / Refund reference.
  • Webhooks — event types + signature verification details.
  • Errors — idempotency rules + retry semantics.
Stuck or shipping production?

We read every developer email.

API questions, OAuth quirks, custom-flow integration help — email support@shiponline.app and a human replies, usually within a few hours.

  • No card to start
  • Labels in 60 seconds
  • Up to 70% off retail