Live delivery rates with a callback

A delivery rate callback lets your server price delivery methods at checkout time. Use it when prices come from a carrier API, a rate-shopping service, or your own logic that a rate table cannot express. When a buyer quotes delivery, Flint sends your endpoint a signed request describing what is shipping and where. Your endpoint returns a price and delivery window for each method.

You need a test API key with delivery write scope, a server that can receive HTTPS requests, and at least one active Location. For local development, a tunnel such as cloudflared tunnel --url http://localhost:3000 gives you a public HTTPS URL. Flint rejects localhost, private IP addresses, and internal hostnames.

How a quote reaches your server#

Quoting a callback-priced methodResponse
BuyerFlintYour rate serverenter a delivery addresssigned POST: currency, destination, and one candidate per methodone outcome per candidate: price, expiry, delivery windowvalidate each outcome against the method's rulesdelivery options with prices
  1. Buyer sends enter a delivery address to Flint
  2. Flint sends signed POST: currency, destination, and one candidate per method to Your rate server
  3. Your rate server returns one outcome per candidate: price, expiry, delivery window to Flint
  4. Flint sends validate each outcome against the method's rules to Flint
  5. Flint returns delivery options with prices to Buyer

Flint sends one request for each group of items delivered together, covering every method in that group that uses the same callback. Methods that send the items from different Locations, such as a warehouse and a store, get separate requests, each with its own execution_legs. Checkout reads never call your server. Only creating a quote does.

A callback has no fallback price. If your endpoint times out, fails, or returns an invalid result for a method, Flint does not offer that method, and other methods in the checkout are unaffected. Pair a callback-priced method with a rate-table or fixed-price method if buyers must always have a choice.

1. Create the callback#

cURL
curl -X POST https://api.withflintpay.com/v1/delivery-rate-callbacks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: rate-callback-carrier-1" \
  -d '{
    "name": "Carrier rates",
    "configuration": {
      "url": "https://rates.example.com/flint/delivery-rates",
      "request_timeout_seconds": 2
    }
  }'
Response
{
  "data": {
    "delivery_rate_callback_id": "dcb_01K4CARRIER",
    "current_delivery_rate_callback_revision_id": "dcbr_01K4CARRIER",
    "name": "Carrier rates",
    "status": "inactive",
    "version": 1,
    "configuration": {
      "url": "https://rates.example.com/flint/delivery-rates",
      "request_timeout_seconds": 2,
      "preview_enabled": false,
      "maximum_request_bytes": 262144,
      "maximum_response_bytes": 262144,
      "redirect_policy": "reject"
    },
    "key_id": "cbkey_3f9a1c2b7d4e5f60a1b2c3d4",
    "secret": "whsec_...",
    "circuit_state": "closed",
    "circuit_failure_count": 0,
    "created_at": "2026-09-22T18:00:00Z",
    "updated_at": "2026-09-22T18:00:00Z"
  },
  "request_id": "req_01K4EXAMPLE"
}

Store secret now. Later reads omit it. The URL must use HTTPS, and Flint never follows redirects. request_timeout_seconds accepts 0.1 to 10 and defaults to 2. Both byte limits accept up to 1,048,576.

The callback starts inactive. Activate it after your endpoint passes the checks in step 3.

Price several methods with one callback#

Each candidate includes a stable delivery_method_id and a delivery_method_revision_id that changes when you edit the method. Use delivery_method_id to choose the service or price list, so standard and express methods can share a callback URL. Echo delivery_method_revision_id in each result so Flint can match it to the candidate.

2. Build the endpoint#

Your endpoint does four things for each request:

  1. Verifies the signature over the raw request body, before parsing JSON.
  2. Returns the stored response when it has already answered this delivery_rate_evaluation_id. Flint reuses the ID when it retries.
  3. Prices each candidate from the destination, weight, and item values in the request.
  4. Returns one outcome per candidate, with every time computed from evaluated_at.

The request is signed with Standard Webhooks, the same scheme as Flint webhooks:

HeaderValue
webhook-idThe delivery_rate_evaluation_id.
webhook-timestampUnix time in seconds when the request was signed.
webhook-signatureOne or more space-separated v1,<signature> values. Each is the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}. The HMAC key is the base64-decoded part of the secret after whsec_.
X-Flint-Event-Typedelivery.rate_evaluation, delivery.rate_evaluation.test, or delivery_rate_callback.connection_check.
X-Flint-Key-IDThe signing key ID. It matches key_id from the create response.
X-Flint-Key-IDsEvery key ID with a signature in the request. Lists two IDs during a secret rotation.

Requests also carry X-Flint-Signature, the older Flint signature format. New handlers can ignore it.

These handlers need no Flint SDK. Each one listens on port 3000 and reads the secret from FLINT_DELIVERY_RATE_SECRETS:

JavaScript
import crypto from "node:crypto";
import express from "express";

const MINUTE = 60 * 1000;
const DAY = 24 * 60 * MINUTE;

// During a secret rotation, list both secrets.
const secrets = process.env.FLINT_DELIVERY_RATE_SECRETS.split(",");
// A retry reuses the evaluation ID. Use a shared store with expiry in production.
const responses = new Map();
// Replace these IDs with the delivery_method_id values from your create responses.
const services = new Map([
  ["dmet_01K4STANDARD", "standard"],
  ["dmet_01K4EXPRESS", "express"],
]);

const app = express();

app.post(
  "/flint/delivery-rates",
  express.raw({ type: "*/*", limit: "256kb" }),
  async (req, res) => {
    try {
      verifySignature(req.body, req.headers, secrets);
    } catch {
      return res.status(401).end();
    }

    if (req.headers["x-flint-event-type"] === "delivery_rate_callback.connection_check") {
      return res.json({});
    }

    const evaluation = JSON.parse(req.body);
    const id = evaluation.delivery_rate_evaluation_id;
    if (!responses.has(id)) {
      responses.set(id, priceEvaluation(evaluation));
    }
    res.json(await responses.get(id));
  },
);

function verifySignature(rawBody, headers, secrets, toleranceSeconds = 300) {
  const id = headers["webhook-id"];
  const timestamp = Number(headers["webhook-timestamp"]);
  const signatures = String(headers["webhook-signature"] ?? "")
    .split(" ")
    .filter((part) => part.startsWith("v1,"))
    .map((part) => Buffer.from(part.slice(3)));

  if (!id || !Number.isInteger(timestamp) || signatures.length === 0) {
    throw new Error("Missing signature headers");
  }
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
    throw new Error("Timestamp outside tolerance");
  }

  const signedContent = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
  const valid = secrets.some((secret) => {
    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const expected = Buffer.from(
      crypto.createHmac("sha256", key).update(signedContent).digest("base64"),
    );
    return signatures.some(
      (signature) =>
        signature.length === expected.length && crypto.timingSafeEqual(signature, expected),
    );
  });
  if (!valid) {
    throw new Error("Signature mismatch");
  }
}

async function priceEvaluation(evaluation) {
  const method_results = await Promise.all(
    evaluation.candidates.map(async (candidate) => ({
      delivery_method_revision_id: candidate.delivery_method_revision_id,
      outcome: await priceCandidate(evaluation, candidate),
    })),
  );
  return {
    delivery_rate_evaluation_id: evaluation.delivery_rate_evaluation_id,
    method_results,
  };
}

async function priceCandidate(evaluation, candidate) {
  const service = evaluation.test ? "standard" : services.get(candidate.delivery_method_id);
  if (!service) {
    return { type: "unavailable", unavailable_reason: "pricing_unavailable" };
  }
  let rate;
  try {
    rate = await quoteCarrier({
      service,
      currency: evaluation.currency,
      destination: evaluation.destination,
      weightGrams: candidate.pricing.total_weight_grams,
    });
  } catch {
    return { type: "cannot_calculate", failure_category: "dependency_failure", retryable: true };
  }
  if (!rate) {
    return { type: "unavailable", unavailable_reason: "destination_not_served" };
  }

  // Compute every time from evaluated_at, not from your server's clock.
  const evaluatedAt = Date.parse(evaluation.evaluated_at);
  const at = (offset) => new Date(evaluatedAt + offset).toISOString();
  return {
    type: "available",
    amount_money: { amount: rate.amount, currency: evaluation.currency },
    expires_at: at(10 * MINUTE),
    selection_guarantee_expires_at: at(30 * MINUTE),
    window_start_at: at(rate.minDays * DAY),
    window_end_at: at((rate.maxDays + 1) * DAY),
    merchant_reference: rate.reference,
  };
}

// Replace with your carrier or rate-shopping API.
async function quoteCarrier({ service, currency, destination, weightGrams }) {
  if (currency !== "USD" || destination?.country !== "US") {
    return null;
  }
  const kilograms = Math.max(1, Math.ceil(weightGrams / 1000));
  return service === "express"
    ? { amount: 1800 + 400 * kilograms, minDays: 1, maxDays: 2, reference: `express-${kilograms}kg` }
    : { amount: 600 + 150 * kilograms, minDays: 3, maxDays: 5, reference: `standard-${kilograms}kg` };
}

app.listen(3000);

Replace quoteCarrier with your carrier or rate-shopping call. The examples keep answered evaluations in memory. With more than one server instance, store them in a shared cache, such as Redis, with an expiry of about 15 minutes.

The handlers return 401 when verification fails and never reveal which check failed. A 401 or other non-2xx status tells Flint the request failed, and Flint retries it once.

What the request contains#

Flint sends the fields a rate calculation needs. It never sends customer names, email addresses, eligibility facts, or metadata.

JSON
{
  "delivery_rate_evaluation_id": "dreval_4c1e9a7b2f3d8e6a0b5c7d9e",
  "key_id": "cbkey_3f9a1c2b7d4e5f60a1b2c3d4",
  "checkout_session_id": "cs_01K4EXAMPLE",
  "order_id": "ord_01K4EXAMPLE",
  "delivery_quote_revision": 2,
  "quote_creation_identity": "dqt_01K4EXAMPLE:2",
  "delivery_feasibility_plan_fingerprint": "7b1d...",
  "delivery_choice_group_id": "dcgrp_01K4EXAMPLE",
  "choice_group_fingerprint": "a93f...",
  "delivery_rate_callback_revision_id": "dcbr_01K4CARRIER",
  "currency": "USD",
  "destination": {
    "state": "NY",
    "postal_code": "11249",
    "country": "US"
  },
  "candidates": [
    {
      "delivery_method_id": "dmet_01K4STANDARD",
      "delivery_method_revision_id": "dmetr_01K4STANDARD",
      "execution_legs": [
        {
          "delivery_execution_leg_id": "dleg_01K4EXAMPLE",
          "fingerprint": "c2e8...",
          "origin_location_id": "loc_01K4WHSE",
          "origin_geography_revision": 3,
          "allocations": [
            {
              "demand_key": "line:li_01K4EXAMPLE",
              "order_line_item_id": "li_01K4EXAMPLE",
              "quantity": 2,
              "merchandise_value_money": { "amount": 5000, "currency": "USD" },
              "allowed_types": ["shipment"]
            }
          ]
        }
      ],
      "pricing": {
        "currency": "USD",
        "bases": {
          "choice_group.fulfillment_quantity": 2,
          "choice_group.merchandise_subtotal_before_discounts": 5000,
          "choice_group.merchandise_subtotal_after_line_item_discounts": 5000,
          "order.merchandise_subtotal_before_discounts": 5000,
          "order.merchandise_subtotal_after_line_item_discounts": 5000
        },
        "total_weight_grams": 2300,
        "total_item_quantity": 2,
        "leg_distance_meters": [8200]
      }
    }
  ],
  "evaluated_at": "2026-09-22T18:04:12.482913Z"
}

The fields a rate calculation usually reads:

currencystring

The checkout currency. Every amount you return must use it.

destinationobject

The delivery address, limited to the fields your method lists in quote_input_fields. Omitted when the method lists none. buyer_location follows the same rule, and adds latitude and longitude when the method lists buyer_location.coordinate.

candidates[].delivery_method_idstring

The stable method ID. Use it to select the service or price list for this candidate, including after you edit the method.

candidates[].delivery_method_revision_idstring

The revision being priced. Echo it in your result.

candidates[].execution_legs[]array

One entry per origin. origin_location_id is the Location the items leave from, and each allocation lists an order line, its quantity, and its merchandise value.

candidates[].pricingobject

Totals for the group: total_weight_grams, total_item_quantity, leg_distance_meters (straight line from each origin to the destination, empty when the address was not geocoded), and bases. bases holds choice_group.fulfillment_quantity and the choice group and order merchandise subtotals in minor units, keyed by the same names as tiered pricing bases. Test deliveries carry the same keys. total_weight_grams is 0 when your products have no weight set.

evaluated_atstring

The quote's evaluation time, in RFC 3339 format. Compute every time you return from this value, not from your server's clock.

testboolean

true on test deliveries from step 3. Omitted on live requests.

Response rules#

Return HTTP 2xx with a Content-Type of application/json and one JSON object:

JSON
{
  "delivery_rate_evaluation_id": "dreval_4c1e9a7b2f3d8e6a0b5c7d9e",
  "method_results": [
    {
      "delivery_method_revision_id": "dmetr_01K4STANDARD",
      "outcome": {
        "type": "available",
        "amount_money": { "amount": 1050, "currency": "USD" },
        "expires_at": "2026-09-22T18:14:12.482Z",
        "selection_guarantee_expires_at": "2026-09-22T18:34:12.482Z",
        "window_start_at": "2026-09-25T18:04:12.482Z",
        "window_end_at": "2026-09-28T18:04:12.482Z",
        "merchant_reference": "standard-3kg"
      }
    }
  ]
}

Flint rejects the whole response when:

  • delivery_rate_evaluation_id does not match the request.
  • The object has unknown fields or trailing data.
  • The body is larger than the callback's maximum_response_bytes.

Flint drops a single method, without affecting the others, when its result is missing, duplicated, or has unknown fields. These parse errors, and every whole-response error, count as a failed request for the circuit breaker described under Timeouts, retries, and failures. An outcome that parses but breaks a rule below also drops only its method, without counting against the circuit.

An available outcome must satisfy all of these:

  • amount_money.currency equals the request's currency, and amount is an integer in minor units within the method's minimum_fee_currency_options and maximum_fee_currency_options. Flint does not clamp amounts.
  • expires_at is later than evaluated_at plus the method's minimum_option_lifetime_seconds, and no later than evaluated_at plus 15 minutes.
  • selection_guarantee_expires_at is present and not earlier than expires_at. It is how long the price holds once the buyer selects it.
  • window_start_at and window_end_at give the delivery window. The start is not earlier than evaluated_at, the end is after the start, and the end is within 366 days. Node and Python drop the digits of evaluated_at beyond milliseconds or microseconds, so do not start a window at exactly evaluated_at. Buyers see the window as arrival_estimate dates, in the timezone of the method's schedule when it has one and otherwise the origin Location's. The end is exclusive, so a window that ends at midnight does not include the next day.
  • service_level, when sent, is valid for the method type: economy, standard, expedited, express, overnight, or same_day for shipment, and on_demand, same_day, or scheduled for local delivery. It replaces the method's configured service level for this option.
  • merchant_reference, when sent, has at most 500 characters and no leading or trailing spaces.

To let the buyer choose a delivery window, send offered_windows instead of window_start_at and window_end_at. Each window has a unique window_id, start_at, end_at, and amount_money, plus an optional selection_guarantee_expires_at. Send at most 50 windows with no overlaps, and set the option's amount_money to the cheapest window's price.

When you cannot offer a method, return one of the other outcome types:

JSON
{ "type": "unavailable", "unavailable_reason": "destination_not_served" }
JSON
{ "type": "cannot_calculate", "failure_category": "dependency_failure", "retryable": true }

unavailable_reason is one of destination_not_served, pickup_unavailable, no_window_available, eligibility_no_match, pricing_unavailable, or inventory_unavailable. Use cannot_calculate when a dependency such as your carrier API fails. Its failure_category is one of dependency_failure, timeout, malformed_response, pricing_failure, schedule_failure, geography_failure, routing_failure, address_verification_failed, or address_could_not_be_verified.

ttl_seconds is optional. Flint caches a response only when none of its outcomes are available, for ttl_seconds (default 120, maximum 300) or 30 seconds when an outcome is cannot_calculate. Available rates are never cached, so every new quote calls your server. A value outside 0 to 300 fails the whole response.

3. Check the connection, then send a test delivery#

Once your endpoint is reachable, confirm that Flint can reach it and that your verification works:

cURL
curl -X POST https://api.withflintpay.com/v1/delivery-rate-callbacks/dcb_01K4CARRIER/check-connection \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: check-carrier-1"

Flint sends a signed {"type":"connection_check"} body with X-Flint-Event-Type: delivery_rate_callback.connection_check. Any 2xx response succeeds:

Response
{
  "data": {
    "delivery_rate_callback_id": "dcb_01K4CARRIER",
    "delivery_rate_callback_revision_id": "dcbr_01K4CARRIER",
    "key_id": "cbkey_3f9a1c2b7d4e5f60a1b2c3d4",
    "status": "succeeded",
    "http_status_code": 200,
    "checked_at": "2026-09-22T18:02:00Z",
    "latency_milliseconds": 84
  },
  "request_id": "req_01K4EXAMPLE"
}

Then send a test delivery with POST /v1/delivery-rate-callbacks/dcb_01K4CARRIER/test-deliveries. Flint sends a sample evaluation with "test": true, one candidate, USD, and a destination of US, NY, 10001. It validates your response with the same rules as a live quote. The sample's method and method revision IDs and Location IDs are random, so your handler must price a candidate it has never seen. The example handlers use standard service for test deliveries.

failure_categoryWhat to check
connection_failed, connection_error, dns_error, tls_errorThe URL is public, resolves, and serves a valid HTTPS certificate.
blocked_targetThe URL resolves to a private or internal address. Use a public host or tunnel.
timeoutYour endpoint answered within request_timeout_seconds.
authentication_or_http_errorYour endpoint returned a non-2xx status. A 401 usually means the wrong secret or a raw body that was parsed before verification.
invalid_responseThe response broke a rule in Response rules.

4. Activate the callback and price a method with it#

Activate the callback with a status-only PATCH:

cURL
curl -X PATCH https://api.withflintpay.com/v1/delivery-rate-callbacks/dcb_01K4CARRIER \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: activate-rate-callback-1" \
  -d '{ "status": "active" }'

Then create a method that uses it:

cURL
curl -X POST https://api.withflintpay.com/v1/delivery-methods \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: method-carrier-standard-1" \
  -d '{
    "name": "Standard shipping",
    "type": "shipment",
    "status": "active",
    "configuration": {
      "origin": { "type": "fixed_location", "location_id": "loc_01K4WHSE" },
      "pricing": {
        "type": "callback",
        "callback": {
          "delivery_rate_callback_id": "dcb_01K4CARRIER",
          "minimum_fee_currency_options": { "USD": { "amount": 0, "currency": "USD" } },
          "maximum_fee_currency_options": { "USD": { "amount": 10000, "currency": "USD" } }
        }
      },
      "minimum_option_lifetime_seconds": 300,
      "estimate": {
        "type": "transit_time",
        "transit_time": {
          "handling_days": { "minimum": 1, "maximum": 1 },
          "transit_days": { "minimum": 2, "maximum": 5 }
        }
      },
      "quote_input_fields": [
        "destination_address.state",
        "destination_address.postal_code",
        "destination_address.country"
      ],
      "public_details": { "service_level": "standard" }
    }
  }'

Copy delivery_method_id from the create response into your server's method-to-service map. Add another method with the same callback ID and map its ID to express to price both through this endpoint.

  • minimum_fee_currency_options and maximum_fee_currency_options are required, with one entry per currency the method sells in. An amount outside them makes the method unavailable, which protects checkout from a bug that returns $0 or $10,000.
  • minimum_option_lifetime_seconds is required and must be below 900. Return an expires_at later than evaluated_at plus this value.
  • estimate cannot be none for callback pricing.
  • quote_input_fields lists the address fields Flint sends to your server. Send destination_address to receive every address line, or list only the fields you need. When a listed field is missing, the quote reports it in input_requirements instead of calling your server.

Offer the method at checkout with settings.checkout.default_delivery_method_ids or a checkout's delivery_method_ids, as in Delivery options. Existing checkouts keep the configuration they pinned when they were created.

To call your server from POST /v1/delivery-previews, set preview_enabled: true on both the callback configuration and the method's pricing.callback. Otherwise previews report the method as needing a checkout.

5. Match the order to your rate#

merchant_reference is private to you. When the buyer selects an option, Flint stores its reference with the selection. It then appears in:

  • Selection reads made with your API key: GET /v1/checkout-sessions/{checkout_session_id}/delivery-selections/current and GET /v1/orders/{order_id}/delivery-selections/current.
  • The order fulfillment's external_reference_id.

Buyers never see it. Use it for the carrier rate or shipment ID you need when you buy the label.

Timeouts, retries, and failures#

  • Timeout. Each attempt gets request_timeout_seconds. Buyers wait on this call, so keep your endpoint fast and set the shortest timeout your carrier allows.
  • Retry. Flint retries once, immediately, after a connection error, a timeout, a non-2xx status, a non-JSON Content-Type, or an oversized body. The retry has the same webhook-id. Flint does not retry a 2xx response it cannot use.
  • Circuit breaker. After 30 consecutive failed requests, Flint stops calling the callback and does not offer its methods. Their outcomes report the failure category callback_circuit_open. After 30 seconds it sends one probe, and a valid response closes the circuit. The callback's circuit_state is closed, open, or half_open, and circuit_failure_count shows the current streak.
  • Diagnostics. Quotes and previews read with your API key report each skipped method's outcome and failure_category, for example callback_transport_failure or callback_result_invalid.

Rotate or revoke the secret#

Rotate the secret on a schedule, or whenever someone who had access leaves:

cURL
curl -X POST https://api.withflintpay.com/v1/delivery-rate-callbacks/dcb_01K4CARRIER/rotate-secret \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: rotate-carrier-2026-09"

The response returns the new key_id and secret. For one hour, Flint signs every request with both the old and new secrets, so webhook-signature carries two signatures and X-Flint-Key-IDs lists both key IDs. Add the new secret to FLINT_DELIVERY_RATE_SECRETS alongside the old one, deploy, then remove the old secret. The overlap is one hour for rate callbacks, shorter than for webhook endpoints.

If a secret leaks, revoke its signing key immediately instead of waiting out the overlap:

cURL
curl -X POST https://api.withflintpay.com/v1/delivery-revocations \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: revoke-cbkey-3f9a" \
  -d '{
    "reason": "credential_compromise",
    "target": {
      "target_type": "callback_signing_key",
      "delivery_rate_callback_signing_key_id": "cbkey_3f9a1c2b7d4e5f60a1b2c3d4"
    }
  }'

Revocation is permanent. Rotate first so a current key exists, then revoke the leaked one. Quotes that depended on it stop being selectable.

Before you go live#

  • Verification reads the raw body, accepts any listed signature, and rejects timestamps more than 5 minutes old.
  • Answered evaluations are stored in a cache every server instance shares.
  • Every time is computed from evaluated_at, and expires_at stays within 15 minutes of it.
  • Amounts fall inside each method's minimum_fee_currency_options and maximum_fee_currency_options.
  • Carrier failures return cannot_calculate instead of an HTTP error, so the circuit breaker counts only real outages.
  • check-connection and test-deliveries both return succeeded against the live URL.
  • A fixed-price or rate-table method is offered alongside the callback method if buyers must always have an option.

Was this helpful?