Async operations

Some work takes longer than a request should. Those endpoints hand you a receipt instead of a result — and you either poll it or let a webhook tell you.

The pattern

An async endpoint returns 202 Accepted with an id and a poll_url. The 202 means we have taken the job, not the job is done. You then poll until the status is terminal.

OperationReturnsPoll
POST /brands/{brand}/media/from-url 202 + job_id GET /brands/{brand}/jobs/{job}
POST /brands/{brand}/reports/{report}/export 202 + delivery_id GET /brands/{brand}/reports/deliveries/{delivery}
POST /brands/{brand}/posts (schedule_type: now) 201 + the post GET /brands/{brand}/posts/{post}/status

Importing media from a URL

curl -X POST "https://vm.viraldashboard.io/api/public/v1/brands/412/media/from-url" \
  -H "Authorization: Bearer $VD_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/banner.jpg", "asset_type": "image" }'
{
  "data": {
    "job_id": 91,
    "status": "pending",
    "poll_url": "https://vm.viraldashboard.io/api/public/v1/brands/412/jobs/91"
  }
}

Poll it until status is completed or failed:

{
  "data": {
    "job_id": 91,
    "status": "completed",
    "workspace_asset_id": 8814,
    "size_bytes": 148233,
    "error": null
  }
}

workspace_asset_id is the media asset you can now attach to a post. On failed, read error.

The URL is fetched by our servers, so it must be publicly reachable. We refuse private and reserved addresses (422 invalid_media_url). Uploading bytes you already hold? Skip the job entirely and POST /brands/{brand}/media as multipart — that one is synchronous.

Exporting a report

curl -X POST "https://vm.viraldashboard.io/api/public/v1/brands/412/reports/18/export" \
  -H "Authorization: Bearer $VD_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "format": "pdf", "start": "2026-06-12", "end": "2026-07-12" }'

That returns a delivery_id. Poll the delivery, and when status is sent download the file:

curl "https://vm.viraldashboard.io/api/public/v1/brands/412/reports/deliveries/77/download" \
  -H "Authorization: Bearer $VD_API_KEY" \
  -o report.pdf

Download before it is ready and you get 409 report_not_ready — that is a "not yet", not a failure. Keep polling.

Polling, without wasting your rate limit

Back off, and give up eventually. Do not spin:

async function waitForJob(brand, jobId, { timeoutMs = 120_000 } = {}) {
  const started = Date.now()
  let delay = 1000

  for (;;) {
    const res = await fetch(`${process.env.VD_API}/brands/${brand}/jobs/${jobId}`, {
      headers: { Authorization: `Bearer ${process.env.VD_API_KEY}` },
    })
    const { data } = await res.json()

    if (data.status === 'completed') return data
    if (data.status === 'failed') throw new Error(data.error ?? 'job failed')

    if (Date.now() - started > timeoutMs) throw new Error('timed out waiting for job')

    await new Promise(r => setTimeout(r, delay))
    delay = Math.min(delay * 1.5, 10_000)      // ease off; cap at 10s
  }
}

Better: do not poll at all

Polling burns rate limit to learn nothing most of the time. For publishing, subscribe to post.published and post.failed and we will call you the moment it settles — zero requests spent waiting. See Webhooks.

A 202 is not a success. The most common integration bug here is treating the accept as the outcome — marking a post "published" in your own database the moment the API returns. The network can still reject it. Wait for the terminal status.