Get started in 5 minutes
Get an API key, create shipments and routes, manage fleet (vehicles, drivers, assignments) and subscribe to webhooks. The API Reference documents the 41 HTTP routes (68 operations) of the current contract.
Get your API key
Ask your administrator to open the dashboard, go to Integrations → API Keys, and generate a key with at least:
shipments:readshipments:writeshipments:deleteroutes:readroutes:writeroutes:deleteroutes:optimizevehicles:readvehicles:writevehicles:deletedrivers:readdrivers:writedrivers:deleteassignments:readassignments:writeassignments:deleteevents:readwebhooks:manage
The key is shown ONCE when created. Store it in your secrets manager. Format: mss_live_* for production or mss_test_* for sandbox.
export MASSIMPLE_BASE=https://api.massimple.la/v1, export MASSIMPLE_KEY=mss_test_xxx.
Validate that the key works
The /v1/whoami endpoint returns the application and company your key points to. Sanity check before anything else.
curl $MASSIMPLE_BASE/whoami \
-H "Authorization: Bearer $MASSIMPLE_KEY"
Expect 200 with:
{
"application": {
"id": "...",
"name": "Mi integración",
"env": "test",
"scopes": ["shipments:write", "events:read", "webhooks:manage"]
},
"company_id": "..."
}
Create your first shipment
POST to /v1/shipments. Do not send companyId or status: the shipment is always created as pending. If your company has a single distribution center, you can omit origin_distribution_center_id.
Main fields (full detail in API Reference → Create shipment):
| Field | Required | Notes |
|---|---|---|
destination | Yes | Destination object |
destination.address | Yes | Address; geocoded if no lat/lng |
destination.latitude / longitude | No* | *Recommended for routing |
content | Yes | Shipment description |
origin_distribution_center_id | No | Required if you have more than one DC |
external_id, amount_to_collect, scheduled_date, metadata | No | Optional |
curl -X POST $MASSIMPLE_BASE/shipments \
-H "Authorization: Bearer $MASSIMPLE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"external_id": "ORD-1001",
"destination": {
"name": "Juan Pérez",
"address": "Av. Corrientes 1234, CABA",
"latitude": -34.6037,
"longitude": -58.3816,
"contact_phone": "+5491155557777"
},
"content": "Caja con dos productos",
"amount_to_collect": 0,
"scheduled_date": "2026-05-20"
}'
201 response with public format: id (shp_...), tracking_number, external_id, etc.
Subscribe to events with a webhook
Register a public HTTPS URL in the dashboard (Integrations → Webhooks) or via API. Every time something changes (shipment status, route status, etc.) we send you a signed POST.
curl -X POST $MASSIMPLE_BASE/webhooks \
-H "Authorization: Bearer $MASSIMPLE_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.tuempresa.com/webhooks/massimple",
"events": ["*"]
}'
We return a secret that is shown ONCE. Store it to verify the HMAC signature of each delivery.
* or the driver tracking preset (route.stop.status_changed, shipment.status_changed, route.optimized, route.status_changed). The event list cannot be edited after creating the webhook.
X-MasSimple-Signature: t=<unix-time>,v1=<hmac-hex>. The signature is computed over <timestamp>.<body-raw> using your secret (whsec_…). Always verify the signature on the RAW body (not parsed).
Flow on your server:
- You receive
POST application/jsonat the registered URL. - Read the body as the exact string (bytes as received).
- Parse
t=andv1=from theX-MasSimple-Signatureheader. - Compute
HMAC-SHA256(secret, "<t>.<body-raw>")in hex. - Compare with
v1(crypto.timingSafeEqual). Reject if the header is missing, the signature is invalid, or the timestamp is > 5 min old. - If valid → parse JSON → process → respond
200.
Verify the signature of each webhook
Verification function + reference Express handler. Environment variable WEBHOOK_SECRET=whsec_….
import crypto from 'crypto';
export function verifySignatureHeader({ header, body, secret, maxAgeSeconds = 300 }) {
if (typeof header !== 'string') return false;
const parts = Object.fromEntries(
header.split(',').map((p) => {
const idx = p.indexOf('=');
return idx === -1 ? [p, ''] : [p.slice(0, idx).trim(), p.slice(idx + 1)];
}),
);
const ts = Number(parts.t);
if (!Number.isFinite(ts)) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > maxAgeSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
const provided = parts.v1 || '';
if (provided.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}
Complete Express handler (use express.raw only on this route, not global express.json()):
import crypto from 'crypto';
import express from 'express';
const SECRET = process.env.WEBHOOK_SECRET;
function verifySignature(header, rawBody) {
return verifySignatureHeader({ header, body: rawBody, secret: SECRET });
}
const app = express();
app.post(
'/webhooks/massimple',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
const sig = req.headers['x-massimple-signature'];
if (!verifySignature(sig, rawBody)) {
return res.status(401).json({ error: 'invalid signature' });
}
const event = JSON.parse(rawBody);
console.log('Evento verificado:', event.type, event.data);
return res.status(200).json({ received: true });
},
);
Manual debug: echo -n "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex — the hex must match v1= in the header.
If the verifier returns true, you can parse the body and process the event. Return 200 at the end — any 5xx triggers our exponential retry (1m, 5m, 30m, 2h, 12h, 24h).
Audit: query the stream with GET /events
Complement to webhooks: pull instead of push. Scope events:read. Lists what +simple emitted (30 days) even if the webhook POST fails or you have no public URL. Same CloudEvents format as a delivery (id, type, time, data).
| Query | Use |
|---|---|
limit | Max per page (default 50, max 200) |
types | Comma-separated filter, e.g. shipment.status_changed,route.stop.status_changed |
after | next_cursor from the previous page |
curl "$MASSIMPLE_BASE/events?limit=50&types=route.stop.status_changed,shipment.status_changed" \
-H "Authorization: Bearer $MASSIMPLE_KEY"
{
"events": [
{
"id": "evt_6a16103d86b6e449bc2ffc76",
"type": "shipment.status_changed",
"source": "masSimple",
"specversion": "1.0",
"time": "2026-05-26T21:27:25.432Z",
"data": { "external_id": "ORD-1001", "current_status": "delivered" }
}
],
"next_cursor": "6a16103d86b6e449bc2ffc77"
}
If next_cursor is not null, request the next page with ?after=next_cursor. In the Bazarshop demo (Events tab) you can try polling with filters without curl. Details in API Reference → Event polling and Dashboard → Integrations → Endpoints.
Operate by external_id
If you store your own order ID (ORD-1001), you don't need Massimple's shp_...:
# Consultar
curl $MASSIMPLE_BASE/shipments/external/ORD-1001 \
-H "Authorization: Bearer $MASSIMPLE_KEY"
# Actualizar (solo content y monto a cobrar)
curl -X PATCH $MASSIMPLE_BASE/shipments/external/ORD-1001 \\
-H "Authorization: Bearer $MASSIMPLE_KEY" \\
-H "Content-Type: application/json" \\
-d '{"content": "Caja actualizada", "amount_to_collect": 1500}'
Listing, updating and deleting by external_id use the same routes with prefix /shipments/external/.... PATCH only accepts content and amount_to_collect (not destination, dates or metadata). To cancel: POST /v1/shipments/{id}/cancel (or .../external/{external_id}/cancel) with optional body {"error_comment":"..."} — no need to send status. Emits shipment.status_changed and shipment.cancelled. The failed status is not in the public API: only the driver marks it from the DriverApp. To recreate a cancelled or failed shipment: POST /v1/shipments/{id}/recreate (or .../external/{external_id}/recreate) with {"scheduled_date":"YYYY-MM-DD"} — creates a new shipment in pending with the same data; the original does not change. Emits shipment.created. For bulk create: POST /v1/shipments/bulk and POST /v1/shipments/bulk-delete (both accept Idempotency-Key).
# Cancelar envío (por id o external_id)
curl -X POST $MASSIMPLE_BASE/shipments/external/ORD-1001/cancel \\
-H "Authorization: Bearer $MASSIMPLE_KEY" \\
-H "Content-Type: application/json" \\
-d '{"error_comment": "Cliente solicitó anulación"}'
# Recrear envío cancelado/fallido (nuevo pending, misma data)
curl -X POST $MASSIMPLE_BASE/shipments/external/ORD-1001/recreate \\
-H "Authorization: Bearer $MASSIMPLE_KEY" \\
-H "Content-Type: application/json" \\
-d '{"scheduled_date": "2026-06-10"}'
Shipments without an assigned route: GET /v1/shipments/available (pending, no assigned_route_id).
Check what's available to assign
There are no separate /unassigned endpoints. Use filters on list endpoints:
| Resource | Request | What it returns |
|---|---|---|
| Shipments to route | GET /shipments/available | Pending without route |
| After deleting a route | GET /shipments/available | Shipments from the deleted route return to pending |
| Routes without driver/vehicle | GET /routes?has_assignment=false | No active assignment |
| Routes ready to assign | GET /routes?has_assignment=false&status=optimized | Optimized, no fleet |
| Available drivers | GET /drivers?available_only=true | Active, no vehicle assigned |
| Available vehicles | GET /vehicles?available_only=true | Available, no driver |
# Rutas sin asignación (ejemplo)
curl "$MASSIMPLE_BASE/routes?has_assignment=false&status=optimized&limit=50" \
-H "Authorization: Bearer $MASSIMPLE_KEY"
Details for each query param in API Reference → List routes.
Create and optimize a route
With scopes routes:write and routes:optimize. You can build a route from pending shipments:
curl -X POST $MASSIMPLE_BASE/routes \
-H "Authorization: Bearer $MASSIMPLE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"external_id": "ROUTE-2026-001",
"name": "Ruta mañana CABA",
"shipment_external_ids": ["ORD-1001", "ORD-1002"],
"optimize": true
}'
201 response with id (rte_...), status (e.g. optimized) and stops_count. To add or remove shipments on an existing route: PATCH /v1/routes/:id with add_shipment_ids / remove_shipment_ids (only draft/optimized without assignment). To reorder stops: PATCH /v1/routes/:id/stops/sequence. To optimize later: POST /v1/routes/:id/optimize.
routes:delete. Only draft or optimized without active assignment. If the route is assigned → first POST /v1/routes/:id/unassign, then DELETE. Response 204 with no body.
pending with assigned_route_id null (even if they were assigned on the route). See them again with GET /v1/shipments/available and build another route with POST /v1/routes.
# Borrar ruta (sin assignment)
curl -i -X DELETE "$MASSIMPLE_BASE/routes/rte_507f1f77bcf86cd799439011" \
-H "Authorization: Bearer $MASSIMPLE_KEY"
# Por external_id del ERP
curl -i -X DELETE "$MASSIMPLE_BASE/routes/external/ROUTE-2026-001" \
-H "Authorization: Bearer $MASSIMPLE_KEY"
With an assignment on the route, DELETE returns 409. Details in API Reference → Routes: delete, unassign and release fleet.
Fleet: vehicle and driver
Scopes vehicles:* and drivers:*. Same patterns as shipments: CRUD, external_id and bulk.
# Vehículo
curl -X POST $MASSIMPLE_BASE/vehicles \
-H "Authorization: Bearer $MASSIMPLE_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_id": "VEH-01",
"license_plate": "AB123CD",
"distribution_center_id": "dc_507f1f77bcf86cd799439012"
}'
# Conductor (sin password → temporary_password en la respuesta)
curl -X POST $MASSIMPLE_BASE/drivers \
-H "Authorization: Bearer $MASSIMPLE_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_id": "DRV-01",
"document": "30111222",
"phone": "+5491112345678",
"license_number": "LIC-123",
"license_type": "B"
}'
The create driver response may include temporary_password once. Store it for the first DriverApp login. If you send password yourself, it is not returned.
DELETE /v1/vehicles/:id and DELETE /v1/drivers/:id return 204 with no body if the resource is free (no active assignment). In your client do not parse JSON: use res.ok or status 204. If there is an assignment → 409.
Edit resources (PATCH)
Each PATCH only accepts specific fields; other fields return 400. Details in API Reference → Partial update.
| Resource | Fields | Notes |
|---|---|---|
| Shipment (PATCH) | content, amount_to_collect | Not if delivered/cancelled/failed |
| Shipment (cancel) | POST .../cancel — optional error_comment | Only cancelled; failed is DriverApp only |
| Shipment (recreate) | POST .../recreate — scheduled_date required | Only if source is cancelled/failed; new pending; emits shipment.created |
| Vehicle | mileage | Required |
| Driver | phone, license_number, preferred_areas, experience | Not name or document |
| Route | add_shipment_*, remove_shipment_* | No assignment; shipments from /shipments/available |
# Vehículo — solo kilometraje
curl -X PATCH $MASSIMPLE_BASE/vehicles/external/VEH-01 \\
-H "Authorization: Bearer $MASSIMPLE_KEY" \\
-H "Content-Type: application/json" \\
-d '{"mileage": 45200}'
# Ruta — agregar/quitar envíos
curl -X PATCH $MASSIMPLE_BASE/routes/external/ROUTE-2026-001 \\
-H "Authorization: Bearer $MASSIMPLE_KEY" \\
-H "Content-Type: application/json" \\
-d '{"add_shipment_external_ids":["ORD-1003"],"remove_shipment_external_ids":["ORD-1001"]}'
Assign driver and vehicle to a route
Scope assignments:write. Driver and vehicle must be in the same distribution center and available.
curl -X POST $MASSIMPLE_BASE/assignments \
-H "Authorization: Bearer $MASSIMPLE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"external_id": "ASG-001",
"driver_external_id": "DRV-01",
"vehicle_external_id": "VEH-01",
"route_external_id": "ROUTE-2026-001"
}'
Before assigning, you can list available resources (step 6b). To release fleet: if the assignment has a route, first POST /v1/routes/:id/unassign (route assigned, not yet started). That unlinks the route and leaves driver+vehicle on the assignment (active). Then POST /v1/assignments/:id/unassign only if route_id is null — with a linked route the assignment endpoint returns 409. The optimized route appears again with GET /routes?has_assignment=false.
Route execution (driver app)
After POST /assignments, the driver operates the route from the DriverApp (sliders). That does not use public /v1 endpoints: it is internal corp / SDUI API (PUT /api/corp/routes/:id/change-status-to-stop, action deliver_current_stop).
pending stops based on traffic (Google Routes). If the order changes, you receive route.optimized on your webhook.
Tracking webhooks. Each driver slider emits route.stop.status_changed and shipment.status_changed. When the last stop is completed, route.status_changed toward completed. The stop payload includes tracking_number, external_id, stop_type, sequence, previous_status and current_status.
| Driver action | Typical webhook events |
|---|---|
| Start / en route | route.stop.status_changed, shipment.status_changed (in_transit) |
| Delivery completed | Same + possible route.optimized |
| Last stop | + route.status_changed → completed |
Audit: GET /v1/events?types=route.stop.status_changed,shipment.status_changed (events:read) lists what was emitted even if the webhook POST fails.
Check the updated sequence with GET /v1/routes/:id/stops. Details in API Reference → Route execution and Dashboard → Integrations → Endpoints.
cd backend && npm run sync-openapi-v2 before deploying the web.