Listings API
Submit business listings in bulk and read the status of every one of them back. Start with a test key: it runs the whole flow, returns realistic statuses immediately, and never puts anything in front of a real directory.
Machine-readable contract: OpenAPI 3.1 specification
Quickstart
Three calls get you from nothing to a listing with a live link: check the key, submit a batch, read the statuses back.
curl https://api.mpowebstudio.com/v1/me \
-H "Authorization: Bearer $MPO_KEY"curl -X POST https://api.mpowebstudio.com/v1/batches \
-H "Authorization: Bearer $MPO_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: my-first-batch" \
-d '{
"reference": "first-run",
"networks": ["bing"],
"listings": [{
"external_id": "cl-1",
"name": "Staff St. Towing & Auto Body",
"address1": "67 Stagg St",
"city": "Brooklyn",
"state": "NY",
"postal_code": "11206",
"phone": "+1-347-554-2285",
"category": "towing",
"owner_email": "owner@example.com"
}]
}'curl "https://api.mpowebstudio.com/v1/listings?batch_id=btc_…" \
-H "Authorization: Bearer $MPO_KEY"Rows a directory would reject come back in step 2 with the field and the reason, instead of being submitted. That is deliberate: a bad row costs you money and costs the listing account its standing.
Authentication and modes
Send your key as a bearer token on every request. The key carries its own mode, so a key sitting in a config file is never ambiguous.
Authorization: Bearer mpo_live_… # real submissions
Authorization: Bearer mpo_test_… # sandbox| Mode | What happens | Use it for |
|---|---|---|
mpo_test_… | Listings are validated exactly as in production, then given simulated outcomes immediately — a realistic mix of live, pending and rejected. Nothing reaches a directory and nothing is billed. | Building and testing your integration |
mpo_live_… | Listings enter the real queue and are submitted to the directory. | Production |
Simulated outcomes are deterministic: the same external_id always gets the same result, so your test suite is stable. A test batch also fires the same signed webhooks a real run would, immediately — so you can verify your signature check before you ever send a live batch. POST /batches and GET /me report which mode produced them via livemode.
Conventions
Every response carries a request id, in the body and in the X-Request-Id header. Quote it in an email and we can find the exact call.
Every error has the same shape, so you branch on type for retry behaviour, on code for the specific case, and show message to a human:
{
"error": {
"type": "invalid_request_error",
"code": "invalid_status",
"message": "Unknown status. Allowed: submitted, pending_verification, live, rejected, needs_data.",
"param": "status",
"docs": "https://mpowebstudio.com/listings/docs#invalid_status",
"request_id": "req_9f2c1a77b3d4e5f6"
}
}type is the coarse switch a bare code cannot give you: api_error and rate_limit_error are worth retrying, invalid_request_error never is. param appears only when one field is at fault, and docs is anchored per code, so the link lands on the paragraph explaining that exact failure.
| `type` | When | Retry? |
|---|---|---|
invalid_request_error | Something in the request is wrong. | No — fix the request. |
authentication_error | Missing or unknown API key. | No. |
conflict_error | The request clashes with existing state. | No — the message says what clashed. |
rate_limit_error | Too many calls on this key. | Yes, after Retry-After. |
api_error | Our side failed. | Yes, with the same Idempotency-Key. |
Rate limits are per key, not per IP, so another integrator behind the same network can never throttle you:
| Endpoint group | Limit | On exceed |
|---|---|---|
POST /batches | 30 per minute | 429 with Retry-After and X-RateLimit-* |
GET /batches/{id}/report | 60 per minute | Counted on its own, not against your reads. |
Reads (/me, /listings, /batches) | 120 per minute | same |
| Webhook management | 20 per minute | same |
Batches
POST /batches takes up to 10,000 listings per call, as JSON or CSV.
Always send an Idempotency-Key. If the same key arrives again with the same body — a timeout, a retry from your queue — you get the original batch back with idempotent_replay: true instead of the listings being submitted and billed twice. If the same key arrives with a different body, you get 409 idempotency_key_reused: reusing a key for new work would silently swallow the new listings, so you need to see it.
{
"batch_id": "btc_77f1c287c434",
"reference": "oct-week1",
"networks": ["bing"],
"livemode": true,
"received": 1000,
"accepted": 987,
"rejected": 13,
"rejections": [
{"external_id": "cl-10244", "field": "phone", "reason": "not a valid number for this country"},
{"external_id": "cl-10250", "field": "category", "reason": "unmapped category: \"duct guy\""},
{"external_id": "cl-10261", "field": "address1", "reason": "must include a street number"},
{"external_id": "cl-10277", "field": "phone", "reason": "duplicate phone, same as cl-10231"}
],
"request_id": "req_05c8e43244ac2dcf"
}CSV works the same way: field names as column headers, Content-Type: text/csv, one listing per row, hours as mon=08:00-18:00;sat=closed. Optional headers: X-Batch-Reference, X-Networks.
GET /batches lists your batches newest first. GET /batches/{id} gives the status counts for one. GET /batches/{id}/report returns the end-of-batch report as CSV, or JSON with ?format=json — live rows first, failures last.
Listing status
GET /listings returns per-listing status, paged with an opaque cursor. Filter by batch_id, status, network; limit up to 500. Page with next_cursor until it comes back null.
| Status | Meaning |
|---|---|
submitted | In our queue, on its way to the directory. |
pending_verification | At the directory. The business owner has to complete the PIN or code step — this part is not ours to do. |
live | Published. live_url and place_id are set. |
rejected | The directory refused it. reason says why. |
needs_data | We need something more from you before it can go out. |
| Network | live_url | place_id |
|---|---|---|
| Bing | https://www.bing.com/maps?ss=ypid.YN873x… | YN873x… |
| Apple (on approval) | https://maps.apple/p/6Z-1yfi2ZWB7pL | IC5B64F43EFA17133 |
The place id is stable. Keep it and you can re-read or update a listing later without searching for it.
Webhooks
Register one HTTPS endpoint and we post an event whenever a listing goes live or is rejected.
curl -X POST https://api.mpowebstudio.com/v1/webhooks \
-H "Authorization: Bearer $MPO_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://yourapp.com/hooks/mpo", "events": ["listing.live", "listing.rejected"]}'The response carries a signing secret, shown once. Every delivery has an X-MPO-Signature header in the form t=1760680802,v1=3f8a1c…, where v1 is the HMAC-SHA256 — keyed with that secret — of the timestamp, a literal dot, and the raw body: "{t}.{raw_body}".
Signing the timestamp alongside the body is what makes a captured delivery expire. Reject anything where t is more than five minutes old, and compare the hex in constant time.
import crypto from "crypto";
app.post("/hooks/mpo", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-MPO-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const { t, v1 } = parts;
// Reject replays: the signature covers the timestamp, so an old capture cannot be reused.
if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", process.env.MPO_WEBHOOK_SECRET)
.update(t + ".") // the timestamp and a literal dot,
.update(req.body) // then the RAW body, before JSON.parse
.digest("hex");
const a = Buffer.from(expected), b = Buffer.from(v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(400);
const { event, data } = JSON.parse(req.body);
// event: "listing.live" | "listing.rejected"
res.sendStatus(200);
});Each event is attempted once, with a five-second timeout, and deliveries are unordered. There is no retry queue today: if your endpoint is down we record the error and move on rather than stalling your listings. Every delivery carries an X-MPO-Event-Id header, repeated as id in the body — dedupe on it, so a retry we add later cannot hurt you.
Deliveries are best-effort: if your endpoint is down we record the error and keep going, so your listings never stall waiting on it. GET /listings stays the source of truth.
Field reference
| Field | Required | Notes |
|---|---|---|
external_id | yes | Your own id, echoed in every status. Max 64 characters. |
name | yes | The business name as it should appear. |
address1 | yes | Must contain a street number. PO boxes are rejected by directories. |
city, state, postal_code | yes | US state codes and ZIP formats are validated. |
country | no | Defaults to US. |
phone | yes | Normalized to E.164. NANP rules enforced: area code and exchange cannot start with 0 or 1. |
category | yes | Free text, mapped to the directory taxonomy. Unmapped values are rejected before submission. |
owner_email or owner_phone | yes | One of the two. The directory sends the verification there. |
website, hours, description | no | Improve the live rate and the quality of the place card. |
Duplicates are detected across your whole account, by phone and by name plus address, and returned as rejections rather than submitted twice.
Errors
| Status | Code | What to do |
|---|---|---|
| 401 | unauthorized | Check the key and the Bearer prefix. |
| 400 | invalid_json, listings_required, empty_batch, too_many_listings | Fix the request shape. |
| 400 | invalid_status, invalid_network, invalid_networks, invalid_cursor, duplicate_external_id | The message lists what is allowed. |
| 400 | url_required, invalid_url, https_required, host_not_allowed, invalid_events | Webhook registration: HTTPS and a public hostname. |
| 404 | not_found | No such batch on this account. |
| 409 | apple_not_available | Apple submission is not open yet. Send "networks": ["bing"]. |
| 402 | insufficient_credit | The batch costs more than the credit left. The body carries required_usd and remaining_usd. |
| 409 | idempotency_key_reused | That key already carried a different body. Use a new key. |
| 413 | payload_too_large | Over 12 MB. Split the batch. |
| 429 | rate_limited | Back off for Retry-After seconds. |
| 503 | storage_failed | Ours. Retry with the same Idempotency-Key — it is safe. |
Timing
Bing: we submit immediately; the business completes PIN verification; the listing publishes 7 to 12 calendar days after that. We resend the PIN once after 7 days and report who has not completed it.
Apple: not open yet. Our partner application is in review. Batches sent with "networks": ["apple"] are refused with 409 rather than silently accepted.
We do not guarantee a publication rate. It depends on your data and on how quickly business owners verify. What we do guarantee is the status and the exact reason for every listing.
Categories
Send category as free text. We map it to the directory taxonomy — including inside a phrase, so “24/7 Emergency Towing Brooklyn” resolves to towing_service. Anything we cannot map comes back as a rejection before submission, with the value you sent. These are the 55 canonical categories:
accountantair_duct_cleaningappliance_repairauto_body_shopauto_glassauto_repair_shopbakerybarber_shopcafecar_washchimney_sweepcleaning_serviceconcrete_contractordaycaredentistdry_cleanerdryer_vent_cleaningelectricianfencing_contractorflooring_contractorfloristgarage_door_servicegeneral_contractorgymhair_salonhandymanhvac_contractorinsurance_agencyjunk_removallandscaperlawyerlocksmithmedical_clinicmoving_companynail_salonpainterpest_controlpet_groomingpharmacyphotographerplumberpool_servicereal_estate_agencyrestaurantroofing_contractorsecurity_servicesolar_installerstorage_facilitytire_shoptowing_servicetree_servicetutoringveterinarianwater_damage_restorationwindow_installationChangelog
Additive changes — new fields, new event types, new categories — ship without a version bump, so treat unknown properties as safe to ignore rather than validating strictly.
2026-09-23
- Test mode: `mpo_test_…` keys run the full flow with simulated, deterministic outcomes.
- `Idempotency-Key` reused with a different body now returns `409 idempotency_key_reused` instead of the original batch.
- `GET /me`, `GET /batches`, and this documentation.
2026-09-22
- Batch reports (`/batches/{id}/report`) as CSV or JSON.
- Signed webhooks for `listing.live` and `listing.rejected`.
- OpenAPI 3.1 specification at `/v1/openapi.json`.
2026-09-21
- First release: batch submission, validation, per-listing status.