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.

1. Check your key
curl https://api.mpowebstudio.com/v1/me \
  -H "Authorization: Bearer $MPO_KEY"
2. Submit a batch
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"
    }]
  }'
3. Read the status back
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.

bash
Authorization: Bearer mpo_live_…   # real submissions
Authorization: Bearer mpo_test_…   # sandbox
ModeWhat happensUse 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:

json
{
  "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`WhenRetry?
invalid_request_errorSomething in the request is wrong.No — fix the request.
authentication_errorMissing or unknown API key.No.
conflict_errorThe request clashes with existing state.No — the message says what clashed.
rate_limit_errorToo many calls on this key.Yes, after Retry-After.
api_errorOur 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 groupLimitOn exceed
POST /batches30 per minute429 with Retry-After and X-RateLimit-*
GET /batches/{id}/report60 per minuteCounted on its own, not against your reads.
Reads (/me, /listings, /batches)120 per minutesame
Webhook management20 per minutesame

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.

Response
{
  "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.

StatusMeaning
submittedIn our queue, on its way to the directory.
pending_verificationAt the directory. The business owner has to complete the PIN or code step — this part is not ours to do.
livePublished. live_url and place_id are set.
rejectedThe directory refused it. reason says why.
needs_dataWe need something more from you before it can go out.
Networklive_urlplace_id
Binghttps://www.bing.com/maps?ss=ypid.YN873x…YN873x…
Apple (on approval)https://maps.apple/p/6Z-1yfi2ZWB7pLIC5B64F43EFA17133

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.

bash
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.

Verifying a delivery (Node)
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

FieldRequiredNotes
external_idyesYour own id, echoed in every status. Max 64 characters.
nameyesThe business name as it should appear.
address1yesMust contain a street number. PO boxes are rejected by directories.
city, state, postal_codeyesUS state codes and ZIP formats are validated.
countrynoDefaults to US.
phoneyesNormalized to E.164. NANP rules enforced: area code and exchange cannot start with 0 or 1.
categoryyesFree text, mapped to the directory taxonomy. Unmapped values are rejected before submission.
owner_email or owner_phoneyesOne of the two. The directory sends the verification there.
website, hours, descriptionnoImprove 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

StatusCodeWhat to do
401unauthorizedCheck the key and the Bearer prefix.
400invalid_json, listings_required, empty_batch, too_many_listingsFix the request shape.
400invalid_status, invalid_network, invalid_networks, invalid_cursor, duplicate_external_idThe message lists what is allowed.
400url_required, invalid_url, https_required, host_not_allowed, invalid_eventsWebhook registration: HTTPS and a public hostname.
404not_foundNo such batch on this account.
409apple_not_availableApple submission is not open yet. Send "networks": ["bing"].
402insufficient_creditThe batch costs more than the credit left. The body carries required_usd and remaining_usd.
409idempotency_key_reusedThat key already carried a different body. Use a new key.
413payload_too_largeOver 12 MB. Split the batch.
429rate_limitedBack off for Retry-After seconds.
503storage_failedOurs. 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_installation

Changelog

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.