# Quickstart

URL: https://submitator.com/docs/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](https://app.submitator.com/agency/start?next=/agency/developers). 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:

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

```bash
curl https://api.submitator.com/v1/ping \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY"
```

```js
const res = await fetch("https://api.submitator.com/v1/ping", {
  headers: { Authorization: `Bearer ${process.env.SUBMITATOR_API_KEY}` },
});
console.log(await res.json());
```

```python
import os
import requests

res = requests.get(
    "https://api.submitator.com/v1/ping",
    headers={"Authorization": f"Bearer {os.environ['SUBMITATOR_API_KEY']}"},
)
print(res.json())
```

Response 200:

```json
{
  "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`](https://submitator.com/docs/errors#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.

```bash
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"}'
```

```js
const res = await fetch("https://api.submitator.com/v1/projects", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SUBMITATOR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://ledgerly.test",
    contact_email: "founder@ledgerly.test",
    external_id: "crm_8812",
  }),
});
console.log(await res.json());
```

```python
import os
import requests

res = requests.post(
    "https://api.submitator.com/v1/projects",
    headers={"Authorization": f"Bearer {os.environ['SUBMITATOR_API_KEY']}"},
    json={
        "url": "https://ledgerly.test",
        "contact_email": "founder@ledgerly.test",
        "external_id": "crm_8812",
    },
)
print(res.json())
```

Response 201 (some fields left out):

```json
{
  "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.

```bash
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"
```

```js
import fs from "node:fs";

const base = "https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP";
const auth = { Authorization: `Bearer ${process.env.SUBMITATOR_API_KEY}` };

await fetch(`${base}/logo`, {
  method: "PUT",
  headers: { ...auth, "Content-Type": "application/json" },
  body: JSON.stringify({ url: "https://ledgerly.test/press/logo.png" }),
});

const form = new FormData();
form.append("file", await fs.openAsBlob("dashboard.png"), "dashboard.png");
const res = await fetch(`${base}/screenshots`, { method: "POST", headers: auth, body: form });
console.log(await res.json());
```

```python
import os
import requests

base = "https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP"
auth = {"Authorization": f"Bearer {os.environ['SUBMITATOR_API_KEY']}"}

requests.put(f"{base}/logo", headers=auth, json={"url": "https://ledgerly.test/press/logo.png"})

with open("dashboard.png", "rb") as image:
    res = requests.post(f"{base}/screenshots", headers=auth, files={"file": image})
print(res.json())
```

Response 200 (some fields left out):

```json
{
  "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.

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

```js
const res = await fetch(
  "https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/readiness",
  { headers: { Authorization: `Bearer ${process.env.SUBMITATOR_API_KEY}` } },
);
console.log(await res.json());
```

```python
import os
import requests

res = requests.get(
    "https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/readiness",
    headers={"Authorization": f"Bearer {os.environ['SUBMITATOR_API_KEY']}"},
)
print(res.json())
```

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

```bash
curl https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/launch \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"directories": "auto"}'
```

```js
const res = await fetch(
  "https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/launch",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SUBMITATOR_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ directories: "auto" }),
  },
);
console.log(await res.json());
```

```python
import os
import requests

res = requests.post(
    "https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/launch",
    headers={"Authorization": f"Bearer {os.environ['SUBMITATOR_API_KEY']}"},
    json={"directories": "auto"},
)
print(res.json())
```

Response 202 (some fields left out):

```json
{
  "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`.

```bash
curl "https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/listings?limit=1" \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY"
```

```js
const res = await fetch(
  "https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/listings?limit=1",
  { headers: { Authorization: `Bearer ${process.env.SUBMITATOR_API_KEY}` } },
);
console.log(await res.json());
```

```python
import os
import requests

res = requests.get(
    "https://api.submitator.com/v1/projects/prj_4QzX1m9Lr2Vb7Nc8Tk3HwP/listings",
    headers={"Authorization": f"Bearer {os.environ['SUBMITATOR_API_KEY']}"},
    params={"limit": 1},
)
print(res.json())
```

Response 200 (some fields left out):

```json
{
  "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.

```bash
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.*"]}'
```

```js
const res = await fetch("https://api.submitator.com/v1/webhook_endpoints", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SUBMITATOR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://hooks.northlight.test/listings",
    events: ["listing.*", "project.*"],
  }),
});
console.log(await res.json());
```

```python
import os
import requests

res = requests.post(
    "https://api.submitator.com/v1/webhook_endpoints",
    headers={"Authorization": f"Bearer {os.environ['SUBMITATOR_API_KEY']}"},
    json={
        "url": "https://hooks.northlight.test/listings",
        "events": ["listing.*", "project.*"],
    },
)
print(res.json())
```

Response 201 (some fields left out):

```json
{
  "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.

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

```js
let listing;
for (let step = 0; step < 3; step++) {
  const res = await fetch(
    "https://api.submitator.com/v1/test_helpers/listings/lst_7Hk2Qn4Rt9Vx1Bm6Cz3LdK/advance",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SUBMITATOR_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: "{}",
    },
  );
  listing = await res.json();
}
console.log(listing);
```

```python
import os
import requests

for step in range(3):
    res = requests.post(
        "https://api.submitator.com/v1/test_helpers/listings/lst_7Hk2Qn4Rt9Vx1Bm6Cz3LdK/advance",
        headers={"Authorization": f"Bearer {os.environ['SUBMITATOR_API_KEY']}"},
        json={},
    )
print(res.json())
```

Response 200 to the third call (some fields left out):

```json
{
  "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:

```http
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:

```js
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);
}
```

```python
# pip install standardwebhooks
import os
from standardwebhooks.webhooks import Webhook

webhook = Webhook(os.environ["SUBMITATOR_WEBHOOK_SECRET"])

# raw_body: the request body as bytes. Raises WebhookVerificationError
# when the delivery is not ours.
def verify_webhook(raw_body, headers):
    return webhook.verify(raw_body, headers)
```

[Signatures](https://submitator.com/docs/webhooks/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

| Code | Status | Fix |
| --- | --- | --- |
| [`not_ready`](https://submitator.com/docs/errors#not_ready) | 422 | The project misses a logo, a screenshot or the contact email. Nothing was spent. |
| [`endpoint_url_invalid`](https://submitator.com/docs/errors#endpoint_url_invalid) | 422 | The webhook URL must be a public `https` URL, without a user name or password. |
| [`test_mode_only`](https://submitator.com/docs/errors#test_mode_only) | 403 | The test helper got a live key. Check that `SUBMITATOR_API_KEY` starts with `sbm_test_`. |
| [`quota_exceeded`](https://submitator.com/docs/errors#quota_exceeded) | 422 | Before your first purchase, test mode allows 25 projects, 100 new projects a day and 3 webhook endpoints. |

## Next steps

- [Onboard a client's product](https://submitator.com/docs/guides/onboard-a-clients-product): autofill, images and the fields directories ask for.
- [Test mode](https://submitator.com/docs/test-mode): what is simulated, and the inputs that fail on purpose.
- [Receive webhooks](https://submitator.com/docs/guides/receive-webhooks): an endpoint that handles every event once.
- [Go-live checklist](https://submitator.com/docs/guides/go-live-checklist): from test mode to your first live launch.
