Quickstart

Get a free test key, launch a client's project in test mode and receive a signed webhook when one of its listings goes live.

This guide takes one client's product from a URL to a live listing in test mode, and receives that change as a signed webhook. Test mode is free: nothing reaches a directory and nothing is spent. Each step shows the request in cURL, Node and Python, and the response under it.

Before you start

  • A free account. Create your agency at app.submitator.com/agency/start. Test mode needs no payment.
  • A test key. In the console, open Developers, choose the TEST tab and create a key. It starts with sbm_test_ and is shown once.
  • An HTTPS URL that answers a POST, for step 6. Your own server works, or a request inspector for a first look.

Put the key in an environment variable, so it stays out of your code:

export SUBMITATOR_API_KEY="sbm_test_..."

The Node examples run as ES modules (.mjs) on Node 20 or later. The Python examples need pip install requests.

1. Check your key

GET /ping returns the account and the key behind the request.

curl https://api.submitator.com/v1/ping \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY"
Response 200
{
  "object": "ping",
  "ok": true,
  "livemode": false,
  "account": { "name": "Northlight Studio", "state": "setup" },
  "key": { "id": "key_3Rw8Kx2Lq9Vt5Nb1Zm7HcW", "permissions": "full" }
}

livemode is false: every call with this key runs in test mode. A 401 means the key is missing or wrong, see api_key_invalid.

2. Create a project

Send the site's URL and a contact email; external_id holds your own reference. Test mode accepts sites on .test, so the example runs as it is.

curl https://api.submitator.com/v1/projects \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://ledgerly.test", "contact_email": "founder@ledgerly.test", "external_id": "crm_8812"}'
Response 201 (some fields left out)
{
  "object": "project",
  "id": "prj_4QzX1m9Lr2Vb7Nc8Tk3HwP",
  "livemode": false,
  "status": "draft",
  "url": "https://ledgerly.test",
  "name": "Ledgerly",
  "contact_email": "founder@ledgerly.test",
  "logo": null,
  "screenshots": [],
  "autofill": { "status": "running", "name_source": "domain", "filled": [], "image_candidates": [] },
  "readiness": { "ready": false, "missing": ["logo", "screenshots"] },
  "external_id": "crm_8812",
  "created_at": "2026-10-03T13:58:12Z",
  "launched_at": null
}

Keep the id: every next step uses it.

3. Add a logo and a screenshot

Import each image from a URL, or upload the file as multipart/form-data with a file field. In test mode we check the request but download and keep nothing, so an image's url is a placeholder.

curl -X PUT https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/logo \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://ledgerly.test/press/logo.png"}'

curl https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/screenshots \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY" \
  -F "file=@dashboard.png"
Response 200 (some fields left out)
{
  "object": "project",
  "id": "prj_4QzX1m9Lr2Vb7Nc8Tk3HwP",
  "status": "draft",
  "logo": { "object": "image", "id": "img_5Mx2Kq8Wt3Lr9Vb7Nz1HdQ", "status": "ready", "error": null },
  "screenshots": [
    { "object": "image", "id": "img_2Kr9Lx3Wq7Mt5Vb8Nz1HcF", "status": "ready", "error": null }
  ],
  "readiness": { "ready": true, "missing": [] }
}

A project holds 1 logo and 1 to 5 screenshots. An image URL that ends in /fail.png fails on purpose, so you can see how your code handles 422 image_unreachable.

4. Preview and launch

GET /projects/{project_id}/readiness is free and shows what a launch would do now. Before your first purchase, test mode picks from the top 20 directories of the catalog, so a launch gets 20 of them at most.

curl https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/readiness \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY"

When ready is true, launch. It uses 1 of your 100 test launches, and "auto" takes the directories the project qualifies for.

curl https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/launch \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"directories": "auto"}'
Response 202 (some fields left out)
{
  "object": "project",
  "id": "prj_4QzX1m9Lr2Vb7Nc8Tk3HwP",
  "livemode": false,
  "status": "in_progress",
  "launched_at": "2026-10-03T14:05:40Z",
  "allowance": { "total": 100, "used": 0, "reserved": 16, "available": 84 },
  "progress": {
    "total": 16,
    "pending": 16,
    "in_progress": 0,
    "action_required": 0,
    "submitted": 0,
    "live": 0,
    "not_accepted": 0,
    "cancelled": 0
  }
}

From here the listings move on their own and the project completes in about 15 minutes. Step 7 moves one of them right away.

5. Find a listing

Each directory gets one listing. Take the first one and keep its id.

curl "https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/listings?limit=1" \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY"
Response 200 (some fields left out)
{
  "object": "list",
  "data": [
    {
      "object": "listing",
      "id": "lst_7Hk2Qn4Rt9Vx1Bm6Cz3LdK",
      "livemode": false,
      "directory": { "id": "dir_2Lm8Wq3Zk7Rt1Xc5Vb9NdF", "name": "BetaList", "domain_rating": 73 },
      "status": "pending",
      "live_url": null
    }
  ],
  "has_more": true,
  "next_cursor": "cur_8Tq2Xn5Wb1Lz7Pd3"
}

6. Add a webhook endpoint

Register the URL that should receive events. Replace https://hooks.northlight.test/listings with yours: it must be a public https URL.

curl https://api.submitator.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.northlight.test/listings", "events": ["listing.*", "project.*"]}'
Response 201 (some fields left out)
{
  "object": "webhook_endpoint",
  "id": "we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT",
  "livemode": false,
  "url": "https://hooks.northlight.test/listings",
  "events": ["listing.*", "project.*"],
  "enabled": true,
  "secret": "whsec_9laRgOMT0C3d8Je6FbArCUY0bGJM+r2emaNUGJCH+aw=",
  "created_at": "2026-10-06T12:00:04Z"
}

The secret is shown in this response only. Store it as SUBMITATOR_WEBHOOK_SECRET for step 8.

7. Move the listing to live

The test helper moves a test listing 1 step along the usual path on each call: in_progress, then submitted, then live. Each move records its event and sends it to your endpoint.

for step in 1 2 3; do
  curl https://api.submitator.com/v1/test_helpers/listings/lst_7Hk2Qn4Rt9Vx1Bm6Cz3LdK/advance \
    -H "Authorization: Bearer $SUBMITATOR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{}'
done
Response 200 to the third call (some fields left out)
{
  "object": "listing",
  "id": "lst_7Hk2Qn4Rt9Vx1Bm6Cz3LdK",
  "livemode": false,
  "directory": { "id": "dir_2Lm8Wq3Zk7Rt1Xc5Vb9NdF", "name": "BetaList", "domain_rating": 73 },
  "status": "live",
  "live_url": "https://betalist.com/startups/ledgerly",
  "live_at": "2026-10-08T14:00:00Z"
}

If the simulation moved the listing first, a call can return 422 validation_failed. Pick another listing from step 5.

8. Check the webhook

Your endpoint received listing.in_progress, listing.submitted and listing.live for this listing. The last one looks like this:

The delivery, its body cut short
POST /listings HTTP/1.1
Host: hooks.northlight.test
Content-Type: application/json
webhook-id: evt_5Rt8Wq2Lm7Xn3Kb9Vz1PdJ
webhook-timestamp: 1791469022
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"object":"event","id":"evt_5Rt8Wq2Lm7Xn3Kb9Vz1PdJ","type":"listing.live","livemode":false,"project":"prj_4QzX1m9Lr2Vb7Nc8Tk3HwP","data":{"object":{"object":"listing","id":"lst_7Hk2Qn4Rt9Vx1Bm6Cz3LdK","status":"live"},"previous_status":"submitted"}}

Check the signature before you trust the body, with the official Standard Webhooks library and your secret. A delivery that fails the check did not come from us: answer 400 and ignore it.

verify-webhook.mjs
import { Webhook } from "standardwebhooks";

// The whsec_ secret of your endpoint, shown once when you added it.
const secret = process.env.SUBMITATOR_WEBHOOK_SECRET;

// Returns the event when the delivery is ours. Throws a
// WebhookVerificationError when the signature does not match or the
// timestamp is more than 5 minutes from your clock.
export function verifyWebhook(rawBody, headers, whsec = secret) {
  return new Webhook(whsec).verify(rawBody, headers);
}

Signatures has the same check in Ruby, PHP and Go, and without a library.

Check it worked

  • GET /webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/deliveries shows the listing.live delivery as succeeded.
  • GET /events?project=prj_4QzX1m9Lr2Vb7Nc8Tk3HwP lists project.launched and every listing event since.
  • GET /projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP shows progress.live of 1 or more.

What can go wrong

CodeStatusFix
not_ready422The project misses a logo, a screenshot or the contact email. Nothing was spent.
endpoint_url_invalid422The webhook URL must be a public https URL, without a user name or password.
test_mode_only403The test helper got a live key. Check that SUBMITATOR_API_KEY starts with sbm_test_.
quota_exceeded422Before your first purchase, test mode allows 25 projects, 100 new projects a day and 3 webhook endpoints.

Next steps