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#
- Buyer sends enter a delivery address to Flint
- Flint sends signed POST: currency, destination, and one candidate per method to Your rate server
- Your rate server returns one outcome per candidate: price, expiry, delivery window to Flint
- Flint sends validate each outcome against the method's rules to Flint
- 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 -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
}
}'
{
"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:
- Verifies the signature over the raw request body, before parsing JSON.
- Returns the stored response when it has already answered this
delivery_rate_evaluation_id. Flint reuses the ID when it retries. - Prices each candidate from the destination, weight, and item values in the request.
- 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:
| Header | Value |
|---|---|
webhook-id | The delivery_rate_evaluation_id. |
webhook-timestamp | Unix time in seconds when the request was signed. |
webhook-signature | One 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-Type | delivery.rate_evaluation, delivery.rate_evaluation.test, or delivery_rate_callback.connection_check. |
X-Flint-Key-ID | The signing key ID. It matches key_id from the create response. |
X-Flint-Key-IDs | Every 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:
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);
import base64
import hashlib
import hmac
import math
import os
import time
from datetime import datetime, timedelta
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
# During a secret rotation, list both secrets.
SECRETS = os.environ["FLINT_DELIVERY_RATE_SECRETS"].split(",")
# A retry reuses the evaluation ID. Use a shared store with expiry in production.
responses = {}
# Replace these IDs with the delivery_method_id values from your create responses.
SERVICES = {"dmet_01K4STANDARD": "standard", "dmet_01K4EXPRESS": "express"}
@app.post("/flint/delivery-rates")
def delivery_rates():
raw_body = request.get_data()
try:
verify_signature(raw_body, request.headers, SECRETS)
except ValueError:
abort(401)
if request.headers.get("X-Flint-Event-Type") == "delivery_rate_callback.connection_check":
return jsonify({})
evaluation = request.get_json(force=True)
evaluation_id = evaluation["delivery_rate_evaluation_id"]
if evaluation_id not in responses:
responses[evaluation_id] = price_evaluation(evaluation)
return jsonify(responses[evaluation_id])
def verify_signature(raw_body: bytes, headers, secrets: list, tolerance_seconds: int = 300) -> None:
message_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "")
signatures = [
part[3:] for part in headers.get("webhook-signature", "").split(" ") if part.startswith("v1,")
]
if not message_id or not timestamp.isdigit() or not signatures:
raise ValueError("Missing signature headers")
if abs(time.time() - int(timestamp)) > tolerance_seconds:
raise ValueError("Timestamp outside tolerance")
signed_content = f"{message_id}.{timestamp}.".encode() + raw_body
for secret in secrets:
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest()).decode()
if any(hmac.compare_digest(expected, signature) for signature in signatures):
return
raise ValueError("Signature mismatch")
def price_evaluation(evaluation: dict) -> dict:
return {
"delivery_rate_evaluation_id": evaluation["delivery_rate_evaluation_id"],
"method_results": [
{
"delivery_method_revision_id": candidate["delivery_method_revision_id"],
"outcome": price_candidate(evaluation, candidate),
}
for candidate in evaluation["candidates"]
],
}
def price_candidate(evaluation: dict, candidate: dict) -> dict:
service = "standard" if evaluation.get("test") else SERVICES.get(candidate["delivery_method_id"])
if service is None:
return {"type": "unavailable", "unavailable_reason": "pricing_unavailable"}
try:
rate = quote_carrier(
service,
evaluation["currency"],
evaluation.get("destination"),
candidate["pricing"]["total_weight_grams"],
)
except Exception:
return {"type": "cannot_calculate", "failure_category": "dependency_failure", "retryable": True}
if rate is None:
return {"type": "unavailable", "unavailable_reason": "destination_not_served"}
# Compute every time from evaluated_at, not from your server's clock.
evaluated_at = datetime.fromisoformat(evaluation["evaluated_at"].replace("Z", "+00:00"))
def at(offset: timedelta) -> str:
return (evaluated_at + offset).isoformat().replace("+00:00", "Z")
return {
"type": "available",
"amount_money": {"amount": rate["amount"], "currency": evaluation["currency"]},
"expires_at": at(timedelta(minutes=10)),
"selection_guarantee_expires_at": at(timedelta(minutes=30)),
"window_start_at": at(timedelta(days=rate["min_days"])),
"window_end_at": at(timedelta(days=rate["max_days"] + 1)),
"merchant_reference": rate["reference"],
}
# Replace with your carrier or rate-shopping API.
def quote_carrier(service, currency, destination, weight_grams):
if currency != "USD" or (destination or {}).get("country") != "US":
return None
kilograms = max(1, math.ceil(weight_grams / 1000))
if service == "express":
return {"amount": 1800 + 400 * kilograms, "min_days": 1, "max_days": 2, "reference": f"express-{kilograms}kg"}
return {"amount": 600 + 150 * kilograms, "min_days": 3, "max_days": 5, "reference": f"standard-{kilograms}kg"}
if __name__ == "__main__":
app.run(port=3000)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
type Money struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
type Evaluation struct {
Test bool `json:"test"`
EvaluationID string `json:"delivery_rate_evaluation_id"`
Currency string `json:"currency"`
Destination map[string]string `json:"destination"`
EvaluatedAt time.Time `json:"evaluated_at"`
Candidates []struct {
MethodID string `json:"delivery_method_id"`
MethodRevisionID string `json:"delivery_method_revision_id"`
Pricing struct {
TotalWeightGrams int64 `json:"total_weight_grams"`
} `json:"pricing"`
} `json:"candidates"`
}
type Outcome struct {
Type string `json:"type"`
AmountMoney *Money `json:"amount_money,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
SelectionGuaranteeExpiresAt string `json:"selection_guarantee_expires_at,omitempty"`
WindowStartAt string `json:"window_start_at,omitempty"`
WindowEndAt string `json:"window_end_at,omitempty"`
MerchantReference string `json:"merchant_reference,omitempty"`
UnavailableReason string `json:"unavailable_reason,omitempty"`
FailureCategory string `json:"failure_category,omitempty"`
Retryable bool `json:"retryable,omitempty"`
}
type MethodResult struct {
MethodRevisionID string `json:"delivery_method_revision_id"`
Outcome Outcome `json:"outcome"`
}
type EvaluationResponse struct {
EvaluationID string `json:"delivery_rate_evaluation_id"`
MethodResults []MethodResult `json:"method_results"`
}
type cachedResponse struct {
once sync.Once
body []byte
}
// During a secret rotation, list both secrets.
var secrets = strings.Split(os.Getenv("FLINT_DELIVERY_RATE_SECRETS"), ",")
// A retry reuses the evaluation ID. Use a shared store with expiry in production.
var responses sync.Map
// Replace these IDs with the delivery_method_id values from your create responses.
var services = map[string]string{
"dmet_01K4STANDARD": "standard",
"dmet_01K4EXPRESS": "express",
}
func main() {
http.HandleFunc("POST /flint/delivery-rates", func(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 256<<10))
if err != nil {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
if err := verifySignature(rawBody, r.Header, secrets, 5*time.Minute); err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
if r.Header.Get("X-Flint-Event-Type") == "delivery_rate_callback.connection_check" {
w.Write([]byte("{}"))
return
}
var evaluation Evaluation
if err := json.Unmarshal(rawBody, &evaluation); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
entry, _ := responses.LoadOrStore(evaluation.EvaluationID, &cachedResponse{})
cached := entry.(*cachedResponse)
cached.once.Do(func() {
cached.body, _ = json.Marshal(priceEvaluation(evaluation))
})
w.Write(cached.body)
})
http.ListenAndServe(":3000", nil)
}
func verifySignature(rawBody []byte, headers http.Header, secrets []string, tolerance time.Duration) error {
id := headers.Get("webhook-id")
timestamp, err := strconv.ParseInt(headers.Get("webhook-timestamp"), 10, 64)
if id == "" || err != nil {
return errors.New("missing signature headers")
}
if math.Abs(time.Since(time.Unix(timestamp, 0)).Seconds()) > tolerance.Seconds() {
return errors.New("timestamp outside tolerance")
}
signedContent := append([]byte(fmt.Sprintf("%s.%d.", id, timestamp)), rawBody...)
for _, secret := range secrets {
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
continue
}
mac := hmac.New(sha256.New, key)
mac.Write(signedContent)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, part := range strings.Split(headers.Get("webhook-signature"), " ") {
if signature, ok := strings.CutPrefix(part, "v1,"); ok &&
hmac.Equal([]byte(signature), []byte(expected)) {
return nil
}
}
}
return errors.New("signature mismatch")
}
func priceEvaluation(evaluation Evaluation) EvaluationResponse {
response := EvaluationResponse{EvaluationID: evaluation.EvaluationID}
for _, candidate := range evaluation.Candidates {
service := services[candidate.MethodID]
if evaluation.Test {
service = "standard"
}
outcome := Outcome{Type: "unavailable", UnavailableReason: "pricing_unavailable"}
if service != "" {
outcome = priceCandidate(service, evaluation, candidate.Pricing.TotalWeightGrams)
}
response.MethodResults = append(response.MethodResults, MethodResult{
MethodRevisionID: candidate.MethodRevisionID,
Outcome: outcome,
})
}
return response
}
func priceCandidate(service string, evaluation Evaluation, weightGrams int64) Outcome {
rate, err := quoteCarrier(service, evaluation.Currency, evaluation.Destination, weightGrams)
if err != nil {
return Outcome{Type: "cannot_calculate", FailureCategory: "dependency_failure", Retryable: true}
}
if rate == nil {
return Outcome{Type: "unavailable", UnavailableReason: "destination_not_served"}
}
// Compute every time from evaluated_at, not from your server's clock.
at := func(offset time.Duration) string {
return evaluation.EvaluatedAt.Add(offset).UTC().Format(time.RFC3339Nano)
}
day := 24 * time.Hour
return Outcome{
Type: "available",
AmountMoney: &Money{Amount: rate.Amount, Currency: evaluation.Currency},
ExpiresAt: at(10 * time.Minute),
SelectionGuaranteeExpiresAt: at(30 * time.Minute),
WindowStartAt: at(time.Duration(rate.MinDays) * day),
WindowEndAt: at(time.Duration(rate.MaxDays+1) * day),
MerchantReference: rate.Reference,
}
}
type carrierRate struct {
Amount int64
MinDays, MaxDays int
Reference string
}
// Replace with your carrier or rate-shopping API.
func quoteCarrier(service, currency string, destination map[string]string, weightGrams int64) (*carrierRate, error) {
if currency != "USD" || destination["country"] != "US" {
return nil, nil
}
kilograms := max(1, (weightGrams+999)/1000)
if service == "express" {
return &carrierRate{1800 + 400*kilograms, 1, 2, fmt.Sprintf("express-%dkg", kilograms)}, nil
}
return &carrierRate{600 + 150*kilograms, 3, 5, fmt.Sprintf("standard-%dkg", kilograms)}, nil
}
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.
{
"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:
currencystringThe checkout currency. Every amount you return must use it.
destinationobjectThe 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_idstringThe 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_idstringThe revision being priced. Echo it in your result.
candidates[].execution_legs[]arrayOne 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[].pricingobjectTotals 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_atstringThe quote's evaluation time, in RFC 3339 format. Compute every time you return from this value, not from your server's clock.
testbooleantrue 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:
{
"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_iddoes 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.currencyequals the request'scurrency, andamountis an integer in minor units within the method'sminimum_fee_currency_optionsandmaximum_fee_currency_options. Flint does not clamp amounts.expires_atis later thanevaluated_atplus the method'sminimum_option_lifetime_seconds, and no later thanevaluated_atplus 15 minutes.selection_guarantee_expires_atis present and not earlier thanexpires_at. It is how long the price holds once the buyer selects it.window_start_atandwindow_end_atgive the delivery window. The start is not earlier thanevaluated_at, the end is after the start, and the end is within 366 days. Node and Python drop the digits ofevaluated_atbeyond milliseconds or microseconds, so do not start a window at exactlyevaluated_at. Buyers see the window asarrival_estimatedates, 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, orsame_dayfor shipment, andon_demand,same_day, orscheduledfor 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:
{ "type": "unavailable", "unavailable_reason": "destination_not_served" }
{ "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 -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:
{
"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_category | What to check |
|---|---|
connection_failed, connection_error, dns_error, tls_error | The URL is public, resolves, and serves a valid HTTPS certificate. |
blocked_target | The URL resolves to a private or internal address. Use a public host or tunnel. |
timeout | Your endpoint answered within request_timeout_seconds. |
authentication_or_http_error | Your endpoint returned a non-2xx status. A 401 usually means the wrong secret or a raw body that was parsed before verification. |
invalid_response | The 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 -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 -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_optionsandmaximum_fee_currency_optionsare 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_secondsis required and must be below 900. Return anexpires_atlater thanevaluated_atplus this value.estimatecannot benonefor callback pricing.quote_input_fieldslists the address fields Flint sends to your server. Senddestination_addressto receive every address line, or list only the fields you need. When a listed field is missing, the quote reports it ininput_requirementsinstead 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/currentandGET /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 samewebhook-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'scircuit_stateisclosed,open, orhalf_open, andcircuit_failure_countshows the current streak. - Diagnostics. Quotes and previews read with your API key report each skipped method's outcome and
failure_category, for examplecallback_transport_failureorcallback_result_invalid.
Rotate or revoke the secret#
Rotate the secret on a schedule, or whenever someone who had access leaves:
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 -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, andexpires_atstays within 15 minutes of it. - Amounts fall inside each method's
minimum_fee_currency_optionsandmaximum_fee_currency_options. - Carrier failures return
cannot_calculateinstead of an HTTP error, so the circuit breaker counts only real outages. check-connectionandtest-deliveriesboth returnsucceededagainst the live URL.- A fixed-price or rate-table method is offered alongside the callback method if buyers must always have an option.
