@vibedout/shipping
v0.1.0
Published
Provider-neutral shipping for Cloudflare Workers: one ShippingProvider interface for rates, labels, tracking, cancellation, pickups, reconciliation and signed webhooks, a Mexican address schema, parcel helpers, an Envia.com adapter and a test double.
Maintainers
Readme
@vibedout/shipping
Provider-neutral shipping for Cloudflare Workers: one ShippingProvider interface for quoting rates, buying a
label from the project's prepaid balance, tracking in batches, voiding a label, scheduling a pickup and reading signed
webhooks; a Mexican address schema with validation; parcels with a volumetric-weight helper; and a test double. The
first provider is Envia (@vibedout/shipping/envia).
Cloudflare Workers + fetch + WebCrypto only, no runtime dependency (no carrier SDK, no Zod). It is built on three
shared kits, @vibedout/web-crypto (HMAC, signature headers, freshness, webhook receipts), @vibedout/provider-kit (HTTP
client, error family, time, validation) and @vibedout/money, which are bundled into its build, so the package imports no
other package at run time. The D1 webhook receipt store, its options type and its migration SQL are re-exported from
@vibedout/shipping/webhook-receipts (and the root), so this package is enough on its own to dedupe webhooks in production.
Two things follow from bundling: the bundled copy of ProviderError is this package's own, so error instanceof ProviderError
with the class from the kit package is false for a ShippingError (error instanceof ShippingError, or error.name, is the
check to use); and a test that wants recordedFetch (@vibedout/provider-kit/testing) installs @vibedout/provider-kit as a
dev dependency once that package is published.
Scope
This package ships the contract, the address schema, the parcel maths, the errors and the fake; the Envia adapter; and the
neutral reconciliation call, listShipments. It does not ship:
- the D1 tables (
shipments,parcels,tracking_events,charges,webhook_receipts), the routes, the copy of the label PDF, the rate picker or the tracking page: those are the application's; - the postal-code to colonia picker: an application builds it on Envia's lookup below, because the SEPOMEX catalogue may not be redistributed;
- other aggregators (Skydropx, say), same-day couriers, returns labels, international shipments, Carta Porte.
Module map
| Import | What |
|---|---|
| @vibedout/shipping | The contract and nothing else: ShippingProvider and its types, ShippingError, address, parcel, Money, status, reconciliation, webhook receipts |
| @vibedout/shipping/provider | ShippingProvider, ShippingCapabilities, ShippingCallOptions, webhookInputFromRequest |
| @vibedout/shipping/types | RateRequest, Rate, ShipmentResult, TrackingResult, ShippingWebhookEvent, Money, WebhookInput, ... |
| @vibedout/shipping/errors | ShippingError, ShippingErrorCode |
| @vibedout/shipping/status | ShipmentStatus, isTerminalStatus, isCancellableStatus |
| @vibedout/shipping/address | MxAddress, parseMxAddress, assertMxAddress, the 32 states, normalizeMxPhone |
| @vibedout/shipping/parcel | Parcel, volumetricWeightKg, chargeableWeightKg, parseParcels |
| @vibedout/shipping/reconcile | findShipment, collectShipments: read listShipments pages to recover a charge or find an orphan label |
| @vibedout/shipping/webhook-receipts | WebhookReceiptStore, handleShippingWebhook, the D1 store (D1ReceiptStore, receiptsMigrationSql) and an in-memory store |
| @vibedout/shipping/adapter-kit | What a provider adapter is written with, not what an application needs: checkRateRequest, checkCreateShipmentInput, checkListShipmentsQuery (the input checks every provider and the fake run first), fanOutRates, sortRates, readProviderAmount, validationError |
| @vibedout/shipping/envia | The Envia adapter, and createEnviaProviderFromEnv(env) which builds it from a Worker's ENVIA_* settings |
| @vibedout/shipping/testing | FakeShippingProvider |
Provider code, the fake and the adapter-author helpers are deliberately off the root
barrel, so an import line shows which provider is in play and every name on the root is
one an application may depend on. Nothing under src/ is importable: only the paths in
this table exist.
The HTTP client, the error-family factory, the signature helpers and the money conversions
are not repeated here: an adapter author imports them from @vibedout/provider-kit,
@vibedout/web-crypto and @vibedout/money.
The interface
interface ShippingProvider {
readonly id: ShippingProviderId; // "envia", "fake"; telemetry only
readonly capabilities: ShippingCapabilities; // { cashOnDelivery, pickup, batchTracking, shipmentCharges }
quoteRates(request: RateRequest, options?: ShippingCallOptions): Promise<{ rates: Rate[]; failures: RateFailure[] }>;
createShipment(input: CreateShipmentInput, options?: ShippingCallOptions): Promise<ShipmentResult>; // spends money
getTracking(refs: TrackingRef[], options?: ShippingCallOptions): Promise<Record<string, TrackingResult>>; // batch
cancel(input: CancelInput, options?: ShippingCallOptions): Promise<CancelResult>; // before the first scan
listShipments(query: ListShipmentsQuery, options?: ShippingCallOptions): Promise<ShipmentPage>; // the provider's record of what you bought: reconciliation
schedulePickup?(input: PickupRequest, options?: ShippingCallOptions): Promise<PickupResult>;
parseWebhook(input: WebhookInput): Promise<ShippingWebhookEvent>; // verifies, then normalizes
}
interface ShippingCallOptions { signal?: AbortSignal }A caller depends on this interface (injected), never on an adapter class. Every method that
talks to the provider takes { signal }: aborting stops the call, as an unavailable error
that is never retryable (for a purchase the outcome is then unknown, see "Retrying a label
purchase"). parseWebhook does no I/O, so it takes none.
import type { ShippingProvider } from "@vibedout/shipping/provider";
async function shipOrder(shipping: ShippingProvider, order: Order) {
const request = { from: warehouse, to: order.address, parcels: [order.parcel] };
const { rates } = await shipping.quoteRates(request); // cheapest first
const rate = rates[0];
if (!rate) return null; // nobody serves this address
return shipping.createShipment({
idempotencyKey: order.shipmentId, // your `shipments` row id
rate, request, labelFormat: "pdf_4x6", reference: order.number,
});
}- Rates.
quoteRatesmay ask several carriers;failureslists the ones that could not answer. Showratesto the buyer, logfailures. An emptyratesis only ever returned when every carrier that answered had nothing and the rest simply refused the route; if anything that could be an outage or a bug is in the way (a timeout, a bad credential, every carrier refusing the request, an unreadable answer) it throws instead, so "we could not ask" is never shown as "nobody serves this address". Pass the wholeRateback tocreateShipment, never just its id. - Labels.
ShipmentResult.labelUrlis the provider's own URL, returned as-is. Copy the file to R2 at once; provider URLs may expire. A label that was bought is never thrown away over an unreadable detail:labelUrlisnullif the provider sent no URL, andchargedisnullif it sent a price this package could not read (record the charge as unknown and recover it withfindShipment, see "Reconciling" below). Each parcel's ownlabelUrlis onparcels[]. - Cancel. Throws
cancel_not_allowedonce the carrier has scanned the parcel.balanceReturned: falseright after a cancellation can mean "refund pending". Cancel is safe to repeat: if an earlier attempt worked but its answer was lost, the repeat comes backalreadyCancelled: true, not as "too late".
Money and units
Money is { amountMinor, currency }, integer minor units (MXN centavos),
one money shape: Money, isMoney and mxn are @vibedout/money's,
re-exported. No float crosses this package's API: providers that speak decimal pesos
(Envia does) are converted at the HTTP boundary with @vibedout/money's toMinor
(readProviderAmount(value, field, currency?) in ./adapter-kit is that conversion, in the minor unit of the currency the
provider's response names, MXN when it names none, throwing this package's invalid_response for an amount it cannot read
exactly). Weights are kilograms,
dimensions centimetres.
Mexican addresses
import { parseMxAddress } from "@vibedout/shipping/address";
const result = parseMxAddress(formBody); // unknown in, normalized out
if (!result.ok) return c.json({ issues: result.issues }, 422);
const address = result.value; // MxAddress| Field | Spanish | Rules |
|---|---|---|
| name | nombre de contacto | required |
| company | empresa | optional |
| phone | teléfono | required; reduced to the 10-digit national number (accepts +52, (55) 1234-5678, 52…, the old +52 1 mobile prefix, 01/044/045) |
| email | correo | optional; shape-checked |
| street | calle | required |
| exteriorNumber | número exterior | required; s/n, SN, sin número become "S/N" |
| interiorNumber | número interior | optional |
| colonia | colonia | required. Pick it from the postal code's list in the UI; free text is accepted here |
| municipio | municipio / alcaldía | required |
| city | ciudad / localidad | optional, when it differs from the municipio. Carriers validate the city strictly and the municipio is not always the city (Cancún is in Benito Juárez): fill it from the postal code's locality |
| state | estado | required; resolved to the INEGI code ("19" is Nuevo León) from a name, abbreviation, ISO code (MX-NLE, NLE) or code; anything else is an error, never a guess. A bare "México" next to a Mexico City postal code (01000–16999) is refused as ambiguous_state: people write it for the city, and the State of México starts at 50000 |
| postalCode | código postal (CP) | required; exactly 5 digits, as a string, leading zero kept ("06600"), never below 01000; a number is refused because it already lost the zero |
| betweenStreets | entre calles | optional |
| references | referencias | optional; carriers print them for the driver |
| rfc | RFC | optional (only for a factura); validated and upper-cased, the generic XAXX010101000 is fine |
parseMxAddress also trims, collapses whitespace, strips control, zero-width and
bidirectional-override characters (a name must not print as something else) and
caps line lengths (120; references 250). It reports every problem in one
pass (issues[] with path, a stable code, a message) so a form can mark all
bad fields at once. It is idempotent. assertMxAddress throws the same issues as
a validation_failed ShippingError. Adapters re-validate whatever they are
given, so a hand-built object cannot reach a carrier malformed.
The 32 states (MX_STATES) carry code (INEGI 01–32), name,
officialName, iso (MX-CMX, ...) and aliases. findMxState("cdmx" | "Edo.
de México" | "N.L." | "9") resolves what people type; getMxState(code)
returns the record.
What this package does not check: that the colonia belongs to the postal
code, or that the postal code belongs to the state. That needs a postal-code catalogue: the
application reads Envia's lookup (lookupPostalCode) for the colonias of a code, and
the catalogue itself may not be bundled.
Parcels and volumetric weight
import { chargeableWeightKg, volumetricWeightKg } from "@vibedout/shipping/parcel";
volumetricWeightKg({ lengthCm: 20, widthCm: 15, heightCm: 2 }); // 0.12
volumetricWeightKg({ lengthCm: 40, widthCm: 30, heightCm: 20 }, 4000); // 6
chargeableWeightKg({ weightKg: 1, lengthCm: 50, widthCm: 40, heightCm: 30 }); // 12Volumetric weight is L × W × H / divisor (cm³ per kg), rounded to the gram.
The default divisor is 5000, the figure Mexican carriers' service catalogues
show for FedEx-style services; other services use 4000 or 6000, so pass the
carrier's own divisor and never hard-code one for a carrier you have not
checked. The chargeable weight is the greater of real and volumetric weight,
not rounded to a carrier's tiers (the provider's own quote does that). An
NFC tag mailer (20 × 15 × 2 cm, 0.1 kg) is 0.12 kg volumetric, so the carrier's
minimum tier applies. A parcel needs a weight of at least 0.01 kg, dimensions of
at least 0.1 cm and a non-empty contents.
Statuses
Every provider maps its own statuses onto one enum, and keeps its own wording in
providerStatus.
| ShipmentStatus | Meaning | Terminal | Cancellable |
|---|---|---|---|
| created | A label exists; not collected yet | no | yes |
| picked_up | The carrier has the parcel | no | no |
| in_transit | Moving between facilities | no | no |
| out_for_delivery | On the last-mile vehicle | no | no |
| delivered | Handed to the recipient | yes | no |
| exception | Needs attention: failed attempt, bad address, lost, damaged, refused, delayed, waiting at a branch. Still can be delivered | no | no |
| returned | Back with the sender | yes | no |
| cancelled | The label was voided | yes | no |
| unknown | The event carries no state (an "information" update, or a word the mapper does not know). Never let it move a shipment | no | no |
Webhooks
parseWebhook verifies the signature over the raw body bytes and returns one
of three normalized events:
| type | When |
|---|---|
| shipment.status_changed | A shipment changed status; carries the normalized status and a TrackingEvent |
| shipment.surcharge | The carrier charged extra (a re-weigh, an address correction) or refunded such a charge; amount is positive, direction says which way the money moved |
| ignored | Correctly signed, but an event type this package does not model: acknowledge it with a 2xx |
A bad signature throws webhook_signature_invalid; a genuine delivery outside the
replay window throws webhook_expired (never retryable by us: answer 4xx and log it,
and widen the window if the provider retries late). The signature is checked first, so a
forged request learns nothing about the window.
Providers deliver at least once, so the same event will arrive twice.
handleShippingWebhook runs the safe order (verify, claim, handle) and releases
the claims if your handler throws, so the provider's retry is processed instead of
swallowed. It is a thin wrapper: the claiming is handleOnce from
@vibedout/web-crypto, which this function feeds the provider's id as the namespace and the
delivery's keys. If a release fails too, the claim is stuck and the provider's retry would be
answered duplicate, losing the event, so that is not swallowed: the call rejects with an
AggregateError whose first error is your handler's own. A delivery is claimed under two keys: its dedupeId (the provider's
delivery id, which travels in an unsigned header) and its contentId (derived
from the signed bytes), so a captured delivery replayed under a fresh id, or a
re-signed retry of the same event, is still a duplicate.
import { ShippingError } from "@vibedout/shipping/errors";
import { createEnviaProvider } from "@vibedout/shipping/envia";
import { webhookInputFromRequest } from "@vibedout/shipping/provider";
import { D1ReceiptStore, handleShippingWebhook } from "@vibedout/shipping/webhook-receipts"; // D1ReceiptStore: one D1 table, `receiptsMigrationSql()` is its migration
app.post("/webhooks/shipping/envia", async (c) => {
try {
const outcome = await handleShippingWebhook({
provider: createEnviaProvider({ token: c.env.ENVIA_TOKEN, environment: "production", webhookSecret: c.env.ENVIA_WEBHOOK_SECRET }),
input: await webhookInputFromRequest(c.req.raw), // the headers and the RAW bytes, never re-encoded
store: new D1ReceiptStore(c.env.DB), // record the effects in the same batch (see below)
handle: async (event) => (event.type === "shipment.surcharge" ? recordCharge(event) : updateTracking(event)),
});
return c.json({ status: outcome.status }); // processed | duplicate | ignored: all 2xx
} catch (error) {
if (error instanceof ShippingError) {
if (error.code === "webhook_signature_invalid" || error.code === "webhook_expired") return c.json({ error: error.code }, 401);
if (error.code === "invalid_response") return c.json({ error: error.code }, 422); // signed but unreadable: retrying will not fix it, so log it loudly
}
// An AggregateError (the handler failed and a claim could not be released) is a stuck claim: log it loudly and answer 5xx; the
// provider's retry will then be answered "duplicate" until the claim is removed by hand.
throw error;
}
});What the store must guarantee, and what this package cannot:
WebhookReceiptStoreis aReceiptStore(also exported under that name):claimmust be atomic (a D1INSERT ... ON CONFLICT DO NOTHINGand a check of the changed-row count, never a SELECT followed by an INSERT;D1ReceiptStoredoes exactly that). Keep receipts at least as long as the provider's replay window (24 hours for Envia by default) and purge them from a cron trigger (purgeClaimedBefore).- If the process dies between the claim and the end of the handler, the claim stays
and the provider's retry is answered
duplicatealthough the effects never happened. The D1 implementation must therefore record the receipt and apply the effects in one batch; this seam only guarantees the order and the release on a thrown error.createMemoryWebhookReceiptStore()(createMemoryReceiptStorefrom the kit) is the test implementation.
Errors
Every method throws ShippingError with a stable code and retryable. It is built with
@vibedout/provider-kit's defineErrorFamily, so it shares one code vocabulary, and one
status mapping, with @vibedout/payments, which is built on the same kits: the shared codes
below, plus the two only shipping has (insufficient_balance, cancel_not_allowed). Besides
code and retryable an error carries status and providerCode (when it came from a
response), retryAfterMs (a rate limit's Retry-After), issues (a validation failure) and
cause.
| code | retryable | Meaning |
|---|---|---|
| validation_failed | no | Our validators rejected the input; nothing was sent. issues lists every problem |
| auth_failed | no | The provider refused the credentials (401, 403) |
| insufficient_balance | no | The prepaid balance cannot cover the label (402) |
| rate_limited | yes | 429. retryAfterMs is the wait the provider asked for, when it said |
| timeout | yes (reads) | The provider did not answer in time, or 408 |
| unavailable | yes (reads) | Network failure, 423, 424 or a 5xx; also a call the caller aborted (that one is never retryable) |
| invalid_request | no | The provider rejected the request (any other 4xx, or an error body) |
| invalid_response | no | The provider answered in a shape this package does not understand (a 3xx is one too: redirects are not followed) |
| not_found | no | 404 |
| cancel_not_allowed | no | The label cannot be voided (already scanned, past the window) |
| unsupported_operation | no | The provider cannot do what was asked for this shipment (a carrier that does not confirm cash on delivery) |
| configuration_invalid | no | The adapter was built with something unusable (no token, no webhook secret, a bad timeout) |
| webhook_signature_invalid | no | A webhook failed verification |
| webhook_expired | no | A webhook whose signature is valid but whose timestamp is outside the replay window |
Retrying a label purchase
retryable: true means "repeating the same call may succeed". For a call that
spends money or books something (createShipment, schedulePickup) a
timeout, a dropped connection, a 5xx, a caller's abort of a request that was already sent (one aborted before anything was sent says so and is safe to repeat), or a success status whose body
cannot be read is ambiguous: the label may already exist. Those failures keep their
code but are retryable: false, with a message that says so (and, for an unreadable
success, a redacted excerpt of the body). Reconcile first, and only then buy again.
Envia documents no idempotency key, so your own shipments row is the only guard
against a double purchase: write it before calling createShipment.
Reconciling: orphan labels and a charge that came back null
ShippingProvider.listShipments is the provider's own record of the labels
bought in a window, one page at a time, and it is how you recover from the two things a
label purchase can leave open:
interface ListShipmentsQuery {
since: Date | string; // inclusive; a Date, "2026-10-07" (UTC midnight), or a date-time that STATES its zone
until?: Date | string; // exclusive; default now + 5 minutes; at most 366 days after `since`
cursor?: string; // the previous page's nextCursor, with the same since and until
includeCharges?: boolean; // fill `charged` (Envia: one more request per page)
}
interface ShipmentPage { shipments: ShipmentSummary[]; nextCursor?: string; partial?: boolean }
interface ShipmentSummary { provider; trackingNumber; carrier; status; providerStatus?; createdAt; charged: Money | null }- Window. Half-open, so two consecutive windows return a label once (a label whose timestamp
states no zone has no place in time and is kept by every window). A date-time without a zone,
an impossible date, a
sincein the future or before 2000, and a window over 366 days are refused before any request. - Leave slack on BOTH ends. Clocks differ: Envia can stamp a label a few seconds ahead of yours, and
the default
until(now plus five minutes) allows for that only a little. Start a few minutes before the purchase attempt and pass an explicituntila few minutes after it. - Order and pages. Oldest first within a page, a label with no stated zone last. A page can be empty while
nextCursoris set (Envia pages by month).collectShipmentsandfindShipmentfrom@vibedout/shipping/reconcilefollow the cursors (14 pages by default). complete.truemeans every page the provider's paging exposed was read and none said it waspartial. It is NOT proof that a label is absent: Envia's month list "may be paginated" without saying how, the adapter reads only the first page of a month, and it can only flag a pagepartialwhen the response carries something that looks like paging metadata (a top-level key such aspagination,next_page,total; row 23). Settle it in the sandbox before treating "not found" as "not bought".chargedisnullfor "not known", never 0: filled only whenincludeChargesis set and the provider can attribute one price to that label.
A label whose price could not be read (ShipmentResult.charged === null; the label is
yours, the provider took the money):
import { findShipment } from "@vibedout/shipping/reconcile";
const { shipment, complete, chargeError } = await findShipment(shipping, {
trackingNumber: label.trackingNumber,
since: new Date(row.createdAt.getTime() - 10 * 60_000), // your own `shipments` row's creation time, minus slack
until: new Date(Date.now() + 10 * 60_000), // ... and slack after
});
if (shipment?.charged) await recordCharge(row, shipment.charged); // the real charge
else await recordChargeUnknown(row); // the provider could not say: compare the prepaid balance before and afterincludeCharges is on by default in findShipment. If reading the charges fails (Envia's invoice listing is down, or
has no such month yet: row 27), the lookup is repeated without them and the failure comes back as chargeError, so a
label that exists is never hidden behind a charge-source fault. shipping.capabilities.shipmentCharges says whether the
provider can state a charge at all. Check complete before trusting a null shipment.
A purchase whose outcome is unknown (a timeout, an unreadable success: the call is
retryable: false and says the label may exist). Never buy again first:
const { shipments, complete } = await collectShipments(shipping, {
since: new Date(attemptStartedAt.getTime() - 10 * 60_000),
until: new Date(Date.now() + 10 * 60_000),
includeCharges: true,
});
const orphans = shipments.filter((s) => !knownTrackingNumbers.has(s.trackingNumber));
// adopt it (record it, with its charge), or cancel it: the balance is refunded before the first scanEnvia's lists carry no order reference, so the match is by tracking number and time.
FakeShippingProvider has hidePriceOnNextPurchase() and loseNextPurchaseResponse() to rehearse
both paths in your tests.
Testing against the interface
import { FakeShippingProvider } from "@vibedout/shipping/testing";
const shipping = new FakeShippingProvider(); // no network, no account
const { rates } = await shipping.quoteRates(request); // validates exactly like the real adapter
const shipment = await shipping.createShipment({ idempotencyKey: "shp_1", rate: rates[0]!, request, labelFormat: "pdf_4x6" });
shipping.advance(shipment.trackingNumber, "picked_up"); // the carrier scans it
await shipping.cancel({ carrier: "fake", trackingNumber: shipment.trackingNumber });
// -> throws cancel_not_allowed: a label can be voided only before the first scan
const event = shipping.surcharge(shipment.trackingNumber, { amountMinor: 8550, currency: "MXN" });
await shipping.parseWebhook(shipping.fakeWebhookInput(event)); // a valid delivery for your route testsA call to the fake whose signal is already aborted is refused (unavailable, not retryable) before it changes anything,
as the real adapter does, so a test of your own abort handling means something; the fake makes no network call, so a
signal that aborts later has nothing to stop.
To test code that talks to Envia itself (an adapter built on the same client), @vibedout/provider-kit/testing
has recordedFetch(fixtures): a fetch that answers from a list, records every call and throws on one nobody
expected. This package's own tests use it.
The fake does not deduplicate createShipment by idempotencyKey, on
purpose: Envia has no such key, and the fake must not hide a missing guard. It runs
the same input checks as the real adapter (checkRateRequest and
checkCreateShipmentInput, one function each, shared), so the fake and the Envia adapter refuse the same bad inputs before
any request.
The Envia adapter (@vibedout/shipping/envia)
Envia.com is the first provider. The adapter is Envia's REST API over fetch with a
Bearer token: no SDK, no dependency. It was written against Envia's official
docs (docs.envia.com, OpenAPI pages, read 2026-10-07) and has not yet run
against a real sandbox; see "Known contradictions and unproven points" below
before trusting any of it with money.
Configuration: your own account
Every project that uses this package brings its own Envia account, API token and webhook secret. Labels are bought from that account's prepaid balance.
import { createEnviaProvider } from "@vibedout/shipping/envia";
const shipping = createEnviaProvider({
token: env.ENVIA_TOKEN, // Envia dashboard > Developers > API Keys
environment: "sandbox", // "sandbox" | "production": required, no default
webhookSecret: env.ENVIA_WEBHOOK_SECRET, // see "Webhooks" below
carriers: ["estafeta", "fedex", "dhl", "paquetexpress"], // optional, see "Rates"
});Secrets are Worker secrets (wrangler secret put ENVIA_TOKEN), declared in
wrangler.jsonc under secrets.required so a missing one fails the deploy, not
the first checkout. .dev.vars.example: ENVIA_TOKEN=, ENVIA_WEBHOOK_SECRET=.
A token works only in the environment that issued it (a sandbox token on the
production host is a 401). The token is redacted from every error message the
adapter raises (Envia's error bodies are untrusted text, and a proxy could echo a
header back), and it is never in a request body or a log line of this package.
createEnviaProviderFromEnv(env, { keyless? }) builds the adapter from the Worker's own settings (ENVIA_TOKEN, ENVIA_ENVIRONMENT,
ENVIA_WEBHOOK_SECRET, and, for a local stand-in, ENVIA_MOCK_URL, refused unless the environment is sandbox) so an application does not
write the reader again: a missing or unusable one is a configuration_invalid error that names the setting. keyless: true is for the
postal-code lookup, which needs neither a token nor an environment.
Bad settings fail at construction, not at the first request: a missing token, a token that cannot be sent in a header, an unknown environment, a non-positive timeout, concurrency or replay window, a malformed carrier list.
| Option | Default | |
|---|---|---|
| token | required | The project's Envia API token |
| environment | required | sandbox → api-test.envia.com, queries.test.envia.com; production → api.envia.com, queries.envia.com. Geocodes has one URL (geocodes.envia.com) and no sandbox |
| webhookSecret | none | Without it parseWebhook refuses every request (configuration_invalid) |
| carriers | discovered | Carrier ids to quote; omit to quote every active Mexican carrier Envia lists (cached for ten minutes) |
| baseUrls | by environment | Override a host (a mock server, a proxy) |
| fetch | global fetch | |
| timeoutMs | 15000 (the provider kit's default) | Per request, over the whole call, body included. Not above 0, or beyond what a timer holds (2147483647): configuration_invalid |
| fanOutConcurrency | 4 | Simultaneous rate calls (Workers keep 6 connections open per request) |
| stateCodes | built-in table | Per-state overrides of INEGI → Envia's 2-letter code |
| rateMode | "per-carrier" | "omit-carrier" is an experiment, see below |
| webhookToleranceSeconds | 86400 | Replay window: 24 hours, because Envia's retry behaviour is undocumented and a window of minutes would drop a retried surcharge after one outage of ours. Replays are stopped by the receipts, which must be kept at least this long |
| includeRaw | false | Keep Envia's untouched row on Rate.raw / ShipmentResult.raw |
How the interface maps to Envia
| Method | Envia call | Notes |
|---|---|---|
| quoteRates | POST /ship/rate/ once per carrier | Fan-out, bounded concurrency; rateId is "<carrier>:<service>" (Envia has no quote id). With COD requested only rows that echo the collection amount are quoted |
| createShipment | POST /ship/generate/ | labelFormat → printFormat/printSize; reference → shipment.orderReference; a price repeated on every row of one shipment is counted once |
| getTracking | POST /ship/generaltrack/ | Batches of 50; numbers Envia does not answer for come back found: false |
| cancel | POST /ship/cancel/ { carrier, trackingNumber } | balanceReturned is a boolean (no amount); a refusal becomes cancel_not_allowed unless tracking shows the label already Canceled (then alreadyCancelled) |
| schedulePickup | POST /ship/pickup/ | HH:MM window → whole hours (start floored, end ceiled, ending by 23:00). Envia charges a pickup fee that the response does not return |
| parseWebhook | n/a | Section "Webhooks" |
| listShipments(query) | GET queries/guide/{MM}/{YYYY} once per calendar month of the window, plus GET queries/invoice/{MM}/{YYYY} with includeCharges | One page = one month; the cursor is the next month (m:2026-10); the month list has no price, the invoice detail fills charged only where it names a label on exactly one row with a positive paid (rows 27, 28). Months read are padded by a day each side but never past now (a month that has not started is not asked for), labels outside [since, until) are filtered out, and the rest are sorted oldest first. Inside a month only the first page is read; a response with paging-like keys marks the page partial (row 23) |
| listShipmentsForMonth({ month, year }, { includeCharges }) | the same two calls for one month | The building block under listShipments, for a caller that already knows the month |
| listCarriers() | GET queries/carrier?country_code=MX | Used for discovery |
| listStates() / loadStateCodes() | GET queries/state?country_code=MX | See "State codes" |
| lookupPostalCode(cp) | GET geocodes/zipcode/MX/{cp} | Keyless; null for an unknown CP; see "Postal codes" |
Envia's Shipping API sometimes answers HTTP 200 with {"meta":"error",…};
every response body is checked for it, and its error.code, when it is an HTTP status from 400 to
599, means what that status means. Errors map as in "Errors" above, with Envia's one addition:
402 → insufficient_balance. Calls go through @vibedout/provider-kit's HTTP client: the token is
removed from every message, stack and cause, redirects are not followed, and a caller's abort
signal is chained to the timeout. A rate quote, a tracking query and a cancellation are POSTs
that only read or are safe to repeat, so their timeouts and 5xx are retryable; a purchase or pickup
that fails ambiguously, or succeeds with a body that cannot be read, is retryable: false with a
message naming the risk. The helper calls (listCarriers, listStates, loadStateCodes,
lookupPostalCode, listShipmentsForMonth) take the same { signal } options object.
Rates: per carrier, with a fan-out
Envia's rate endpoint takes one carrier per request (its OpenAPI schema marks
shipment.carrier required), so the adapter calls it once per carrier through
fanOutRates (bounded concurrency, partial results, a carrier that fails is a
failure as long as there are rates to show). Which carriers: RateRequest.carriers,
else the carriers option, else the active Mexican carriers from listCarriers().
Cash on delivery: RateRequest.cod (MXN) is sent as the cash_on_delivery
additional service on the first parcel. Envia's guide says a service missing from
the quote is not available for that carrier and that the rate row reports the collection
under cashOnDeliveryAmount (and the fee under cashOnDeliveryCommission), so the
adapter keeps only rows whose cashOnDeliveryAmount equals the requested amount; a
carrier that answers without confirming it is a failure (unsupported_operation),
never a rate that would buy a label and collect nothing. If no carrier confirms,
quoteRates throws unsupported_operation. Rate.codFee is informational: whether
totalPrice already includes the commission is not documented, so it is not added.
Webhooks
Register Envia's signed v2 types: simpleTracking (type 3, event
tracking.simple), ecommerceTracking (4, tracking.ecommerce) and surcharge
(5). Its legacy types 1 and 2 are unsigned and are refused on purpose: anyone
who knows the URL could forge a "delivered".
Each delivery carries X-Webhook-Event, X-Webhook-Id, X-Webhook-Timestamp and
X-Webhook-Signature: v1=HMAC-SHA256(secret, timestamp + "." + event + "." + body).
The adapter verifies it in constant time over the raw bytes (a body that is not valid
UTF-8 is still verified, not mangled by decoding; if the docs' "body_json" means the
re-serialized JSON, that reading is tried too), accepts two signatures in one header (a
secret rotation), and only after the signature held checks the replay window
(webhook_expired, distinct from a bad signature). X-Webhook-Id becomes dedupeId;
contentId is derived from the signed bytes. The adapter does not check the
Authorization: Bearer … header Envia may add "if configured": the HMAC is the
authentication. Your endpoint must answer within 5 seconds with a 2xx, so do the work
in a Queue and acknowledge first.
occurredAt is the body's created_at when that states its zone (Z or an offset). Envia
writes some timestamps with no zone (2025-10-10 12:31:00); read in the host's zone one of
those would come out hours off, so a created_at with no zone is not used and occurredAt is
the time the delivery was signed (X-Webhook-Timestamp) instead (row 29).
The surcharge event becomes shipment.surcharge with amount in integer
centavos (Envia sends decimal pesos), direction charge or refund, the
category (Overweight, ...) and the reason: record it against the shipment.
Statuses
Envia sends the status as a word in tracking responses and webhooks; the numeric
id exists only in its catalogue table. mapEnviaStatus takes either (case,
underscores, hyphens and accents do not matter) and answers unknown for anything
it does not recognise.
| Id | Envia | Normalized | Id | Envia | Normalized |
|---|---|---|---|---|---|
| 1 | Created | created | 15 | Redirected | in_transit |
| 2 | Shipped | in_transit | 16 | Out for Pickup | created |
| 3 | Delivered | delivered | 17 | 1 delivery attempt | exception |
| 4 | Canceled | cancelled | 18 | 2 delivery attempts | exception |
| 5 | Information | unknown | 19 | 3 delivery attempts | exception |
| 6 | N/A | unknown | 20 | Return problem | exception |
| 7 | Pending | created | 21 | Address error | exception |
| 8 | Picked Up | picked_up | 22 | Undeliverable | exception |
| 9 | Out for Delivery | out_for_delivery | 23 | Delayed | exception |
| 10 | Lost | exception | 24 | Rejected | exception |
| 11 | Returned | returned | 25 | 1 pickup attempt | exception |
| 12 | Pickup at Office | exception | 26 | Partially Shipped | in_transit |
| 13 | Delivered at Origin | returned | 27 | Partially Delivered | in_transit |
| 14 | Damaged | exception | 28 | Delivery Attempt | exception |
Plus "In transit" (in every tracking example of the docs, missing from the
catalogue) → in_transit. Envia's docs describe each status but never say how a
shop should treat it; the judgement calls (a waiting-at-branch parcel is an
exception, Shipped is in_transit) are pinned by the package's tests.
State codes
Envia wants its own 2-letter state codes (its FAQ: they "may differ from codes used
by other platforms"). NL, AG, BC and CX (Ciudad de México) come from Envia's
docs. The other 28 in the built-in table are expected values. It was
reported (not re-run, and not recorded) that Envia's own
keyless geocodes endpoint returned the table's code for every state, one postal code per
state, on 2026-10-07: that would corroborate the table but is not the authoritative
/state list. Before relying on the table, run once against the sandbox:
const { loaded, unmatched } = await shipping.loadStateCodes(); // GET /state?country_code=MX, matched by state nameand compare with DEFAULT_ENVIA_STATE_CODES. A "no coverage" error on one state
only is a wrong code in the table.
Postal codes
lookupPostalCode("64060") reads Envia's free, keyless geocodes endpoint:
{ postalCode, city, municipio, state (INEGI), providerState, colonias, coordinates }. The docs
and the live service disagree. The docs show { success, data: { zipcode, city, state,
country, coordinates } }; the live service (three keyless requests recorded on 2026-10-07)
returns a JSON array ([] for an unknown postal code)
of { zip_code, locality, suburbs[], state: { code: { "2digit" } }, coordinates, regions }. Both
are read (city is the live locality; only the live shape has suburbs, which become colonias,
and regions.region_2, which becomes municipio), and any other shape is an
invalid_response, never a quiet null, because a null for every real postal code
would reject every address. Envia documents the lookup as keyless and "useful for address validation and
auto-completion features"; it promises no rate limit.
An application builds the colonia picker on it.
Known contradictions and unproven points
Envia's docs disagree with themselves in several places, and the adapter has not met a real sandbox. Each row says what the adapter does and what proves it.
| # | Point | What the docs say | What the adapter does | Settle it by |
|---|---|---|---|---|
| 1 | One carrier or all carriers per rate call | FAQ, same entry: "Omit the carrier field to get rates from all available carriers" and "accepts one carrier per request". The rate page says "multiple carriers in a single request"; the OpenAPI schema marks shipment.carrier required | One call per carrier, fanned out | Sandbox: rateMode: "omit-carrier" and count the carriers that come back. If it returns all, switch the default and drop the fan-out |
| 2 | Webhook secret | No page says where the HMAC secret comes from or how to set it | webhookSecret option; none → every webhook refused | Sandbox: find where the dashboard or the webhook-creation response exposes it |
| 3 | Signature encoding | v1=a3f2... (looks hex); not stated | Hex, base64 also accepted | A real signed delivery |
| 4 | Timestamp unit | Guide: "Unix timestamp in milliseconds". Reference example: 1705312200 (seconds) | Both accepted (a value of 1e12 or more is milliseconds). In seconds the clock is compared to the whole second | A real signed delivery |
| 5 | What is signed | body_json | The raw request bytes, then the re-serialized JSON if that differs (neither can be forged without the secret) | A real signed delivery (a recorded one becomes the fixture) |
| 6 | Retries and the replay window | Retry policy undocumented | 24 hour window; dedupeId + contentId make replays harmless | Observe a retried delivery's timestamp and id |
| 7 | Registering webhooks | The guide and the Queries reference document POST queries.envia.com/webhooks; the test-webhook reference says there is no API to register them, only the dashboard (Settings → Developers → Webhooks) | The adapter does not register webhooks | Try the API in the sandbox; otherwise use the dashboard |
| 8 | Testing webhooks | The test endpoint is /ship/webhooktest/ in the guide and /ship/webhook/test/ in the FAQ; the guide sends webhook_url, the reference says no URL is passed; the payload is {carrier, tracking_number, shipment_status}, not the v2 envelope; it never signs | Not used | A real signed delivery from a sandbox shipment; there is no recorded signed fixture yet, the signed fixtures in the package's tests are built with our own HMAC and checked against an independent Python HMAC vector |
| 9 | Event names | The X-Webhook-Event examples are tracking.simple and surcharge; the guide lists type 4 as ecommerceTracking (where it lists type 3 as tracking.simple), the reference names its event tracking.ecommerce | Both spellings accepted | |
| 10 | Cancel input | Reference and core guide: carrier + trackingNumber. FAQ: "use the shipmentId" | carrier + trackingNumber (three sources to one) | Sandbox |
| 11 | Cancel refusal and the tracking of a cancelled label | Only success is documented; "labels that have already been scanned cannot be cancelled" with no error shape. Whether the tracking endpoint reports a cancelled label as Canceled is not documented | Any invalid_request is a refusal, kept with Envia's words; before reporting it, tracking is asked once and a Canceled label is returned as alreadyCancelled. A 200 carrying a data object is taken as success | Sandbox: cancel twice, cancel after a scan |
| 12 | Additional services (COD) placement and confirmation | Core guide and the OpenAPI schema: packages[].additionalServices. The additional-services guide shows a top-level additionalServices. Whether cashOnDeliveryAmount echoes the requested amount, and whether totalPrice includes cashOnDeliveryCommission, is not stated | packages[] on the first parcel; rows kept only when cashOnDeliveryAmount equals the request (fail closed); the commission is not added to the total | Sandbox with a COD-capable carrier. If the echo field differs, no COD rate will appear: a visible failure, not a wrong label |
| 13 | State code of Mexico City | CX in three guides; DF only in Argentina examples | CX; see "State codes" | loadStateCodes() |
| 14 | Phone | phone_code examples disagree ("+52", "52", "MX"); the Argentina examples send a national number with no code | 10-digit phone, no phone_code | Sandbox |
| 15 | Interior number | No field in the rate or label schema (only the pickup schema has interior_number) | Appended to number ("3000 Int. 4B") | Print a sandbox label and look |
| 16 | City | city must be valid ("carriers validate strictly"); Envia's CDMX example sends "Ciudad de México" for a Coyoacán address; the live geocodes locality differs from the municipio in Mexico City, Cancún, parts of the State of México and Tabasco | address.city, else "Ciudad de México" for state 09, else the municipio. The address form should fill city from the postal code's locality | Sandbox with addresses from those places |
| 17 | Tracking response | The OpenAPI page documents no response schema; the guides show status as a word ("In transit") and events with timestamp, location, description only. A timestamp without a zone is not described | Word or id for the status; an event's own status is unknown unless Envia sends one; a timestamp without a zone is kept as text, never read in the host's timezone | A real tracking response (becomes the fixture) |
| 18 | Pickup response, fee and carriers | No schema on the OpenAPI page; the guides show data.confirmation and status. The pickup page says it "charges the applicable pickup fee", which the response does not report. Pickup varies per carrier (pickup, pickup_on_generate, or none) | Standalone POST /ship/pickup/ only; a carrier that does not support it is refused by Envia (invalid_request). The fee is not in PickupResult: it shows in the balance | Sandbox per carrier (GET /carrier-action) |
| 19 | Cancel refund | balanceReturned is a boolean; refunds "may take time" in production | CancelResult.balanceReturned; no amount (the caller knows what it paid) | |
| 20 | Idempotency | None documented | None claimed. The caller's shipments row is the guard | |
| 21 | Multi-parcel labels | "Each package gets its own tracking number" (FAQ); packages[] in the label response schema; data is "generated labels", each with totalPrice. Whether the shipment total repeats on each row is not stated | All trackings in parcels with each row's own labelUrl; rows sharing a shipmentId count once, other rows are summed; an unreadable price makes charged null | Sandbox with two parcels |
| 22 | Label URL lifetime | Not stated | Returned as-is; copy it to your own storage if you need it to outlive Envia's link | |
| 23 | Listing shipments | GET /guide/{MM}/{YYYY} lists a month with tracking_number, carrier, status, created_at and "may be paginated; check the response for pagination metadata" without saying how or showing any; no order reference, no price; no ordering is promised | First page of each month only, sorted by us; a page whose response has a top-level key that looks like paging (pagination, next_page, total, cursor, limit, ...) is flagged partial; reconcile by tracking number and time | Sandbox with more shipments than a page: see what the response carries, then follow it |
| 24 | Dedupe advice | The FAQ says to track "the shipmentId and event type"; the webhooks guide says X-Webhook-Id | X-Webhook-Id plus a content key from the signed bytes | |
| 25 | Postal-code lookup shape | Docs: { success, data: { zipcode, city, state, country, coordinates } }. Live, recorded 2026-10-07 for 06600, 64060 and 77500 (three recorded responses): a JSON array with zip_code, locality, suburbs[], state.code["2digit"], coordinates and regions.region_2, which is the municipio in all three | Both read (the municipio from the live shape only); anything else is an error | Settled for the shape. Still open: rate limits and whether suburbs match SEPOMEX |
| 26 | Sandbox behaviour | Rates are simulated; labels are not valid for pickup; sandbox pickups are simulated | | |
| 27 | Invoice detail as the source of a label's charge | GET /invoice/{MM}/{YYYY} returns data[] of tracking_number, carrier, created_at, kilograms, insurance, extended_zone, overweight, paid ("Paid amount"), balance ("Account balance"), event, description. No example response; nothing says one tracking number has one row (a label and a later surcharge may be two), whether paid is the label price or something running, or its currency | charged is read only for a tracking number on exactly ONE row whose paid is a positive decimal (text or number), in MXN; several rows, a zero (may mean "not invoiced yet"), an unreadable amount, or no row leave it null. A response with no data[] is an invalid_response and fails the call (findShipment then repeats the lookup without charges and reports chargeError) | Sandbox: buy a label and a surcharge, read the month's invoice, compare paid with the purchase's totalPrice; whether the CURRENT month's invoice exists before anything is invoiced, or answers 404 or an empty list; whether a lone row can be a refund or a running figure instead of a label price. The fixture invoice-month.json is handwritten from the schema |
| 28 | Which timezone Envia cuts its months in | GET /guide/{MM}/{YYYY} takes a calendar month; no timezone is stated | Months are UTC; the window is read one day wider on each side and labels are filtered by their own created_at, so a label near midnight on the 1st is found whichever month Envia put it in. A created_at without a zone is kept, not dropped. Not asked for: a month that has not started. Whether a month with no shipments answers an empty list or a 404 is also undocumented (a 404 would fail that page with not_found) | Sandbox: buy a label near a month boundary (Mexico City, UTC-6) and see which month lists it; list a month with nothing in it |
| 29 | Timezone of the webhook's created_at | The webhooks guide shows ISO timestamps with Z; elsewhere (cancel, tracking) Envia sends "2025-10-10 12:31:00" with no zone, and nothing says which zone that is | occurredAt uses created_at only when it states its zone, else the signing time of the delivery (never the host's zone) | A real signed delivery: whether created_at ever lacks a zone, and if it does, which zone Envia means |
| 30 | Redirects and trailing slashes | The paths are written inconsistently across the docs: /ship/rate/ with a trailing slash, /carrier?country_code=MX and /guide/10/2026 without. Nothing says whether Envia redirects one spelling to the other, or http to https | Redirects are not followed (the kit's default, so a credential header cannot be carried to another host); a 3xx is an invalid_response carrying the status, and the paths are as documented | Sandbox: call each documented path once and look for a 301 or 308. If one redirects, fix the path; if the API redirects as a rule, add a per-client redirect option (a decision to take with the Authorization header in mind) |
The package's JSON test fixtures are modelled on the response examples in Envia's docs; apart from the three postal-code lookups, none is a recording of a real response.
Changelog
- 0.1.0: the first release. The
ShippingProvidercontract, the Mexican address schema, parcels and volumetric weight, the Envia adapter (rates, labels, tracking, cancellation, pickups, reconciliation and signed webhooks),handleShippingWebhookwith receipt-store dedupe (the D1 store and its migration are exported from./webhook-receipts), and the fake.
