---
name: answer-page-integration
description: Connect a server or agent to answer.page with an organization API key, research jobs, live job events (SSE), stored Markdown, and signed webhooks.
---

# Integrate with answer.page

Use this guide to create research pages from another system and retrieve the finished, cited Markdown. The API is server-to-server. It assumes you already have an organization API key. To request access, email [abhishek@instantlycreative.com](mailto:abhishek@instantlycreative.com).

## 1. Keep your API key on your server

Store the API key in your server-side secret store. Do not put it in source control, a browser app, a public page, or logs. If the key is lost or exposed, email [abhishek@instantlycreative.com](mailto:abhishek@instantlycreative.com) for a replacement.

## 2. Configure the external system

This deployment's base URL is `https://www.answer.page`. Set it in your external server's environment:

```sh
export BASE_URL="https://www.answer.page"
```

Set `ANSWER_PAGE_API_KEY` in the **external server's** secret store to your organization API key. The examples below assume both variables are available in your shell. Never send the key to a browser or add it to a query string. All private API requests use:

```http
Authorization: Bearer ORGANIZATION_API_KEY
```

The key determines the organization; request bodies cannot choose a different tenant. Use HTTPS for a deployed instance. Public page URLs and original questions are readable by anyone with the link; do not submit secrets or private questions.

## 3. Start research

Create a unique idempotency key for each _new_ request, and reuse **that same key and body** when retrying a request whose response was lost. A new key starts a new job. Keys can be up to 255 characters; conflicting reuse returns `409`. Records expire after 24 hours. Save the key before sending the request (this shell example uses Node.js to make a UUID):

```sh
IDEMPOTENCY_KEY=$(node -e 'process.stdout.write(crypto.randomUUID())')
curl --request POST "$BASE_URL/api/v1/pages" \
  --header "Authorization: Bearer $ANSWER_PAGE_API_KEY" \
  --header 'Content-Type: application/json' \
  --header "Idempotency-Key: $IDEMPOTENCY_KEY" \
  --data '{
    "mode": "research",
    "question": "What is the difference between CSS Grid and Flexbox?",
    "depth": "quick",
    "metadata": { "customerId": "cus_123" }
  }'
```

On a retry, reuse `IDEMPOTENCY_KEY` and the exact same body rather than generating a new key. `depth` is `quick` (default) or `thorough`. `question` has a 10,000-character limit. `metadata` is optional, holds at most 20 string pairs (keys at most 64 characters, values at most 500), and comes back in terminal events and webhooks. Avoid sensitive values in metadata.

A valid research request returns **`202 Accepted`**. Save `data.id` as `PAGE_ID`, `meta.job.id` as `JOB_ID`, and `meta.publicUrl` as the shareable path. The response's `Location` header is the private job URL; `Retry-After` is `2` seconds. `meta.publicUrl` is relative to `BASE_URL`. The page can still be pending when the response arrives; do not treat `202` as a finished answer.

If you already have Markdown and do not want research, send `{"mode":"render","markdown":"# Answer\n\nComplete text."}` to the same endpoint instead. That path returns `201` with a completed page and makes no research-provider call; it does not need `Idempotency-Key`. Stored Markdown is the canonical page content; `resources` hints are optional.

## 4. Wait for the job to finish

Pick one way to learn when the job ends: stream its events (below), poll it, or use webhooks (step 6). Do not create another research job just because this one is taking time. Most research jobs take about a minute.

### Option A: stream job events (SSE)

`GET /api/v1/jobs/{jobId}/events` is a [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) stream. The job response also gives this path as `meta.eventsUrl`. Send the same bearer header. Try it with curl:

```sh
curl --no-buffer "$BASE_URL/api/v1/jobs/$JOB_ID/events" \
  --header "Authorization: Bearer $ANSWER_PAGE_API_KEY" \
  --header 'Accept: text/event-stream'
```

The stream starts with `retry: 2000`, then sends one frame per event. Each frame has a numeric `id`, an `event` name equal to the payload's `type`, and one line of JSON `data`:

```text
id: 41
event: job.status
data: {"type":"job.status","jobId":"JOB_ID","pageId":"PAGE_ID","status":"researching","stage":"searching"}

id: 44
event: job.completed
data: {"type":"job.completed","jobId":"JOB_ID","pageId":"PAGE_ID","revisionId":"REVISION_ID","metadata":{"customerId":"cus_123"}}
```

| Event                 | Extra `data` fields                                                     | Ends stream |
| --------------------- | ----------------------------------------------------------------------- | ----------- |
| `job.status`          | `status` (`queued`, `researching`, `assembling`) and `stage` (`queued`, `searching`, `reading`, `writing`, `validating`) | No  |
| `job.completed`       | `revisionId`, `metadata`                                                | Yes         |
| `job.failed`          | `failure` (`code`, `message`, `retryable`), `metadata`                  | Yes         |
| `job.action_required` | `metadata`. The job status is now `unknown`; do **not** blindly resubmit | Yes         |

Every event also has `jobId` and `pageId`. Treat `job.status` as progress only: stages can be skipped, and your code should not depend on their exact order.

How the stream behaves:

- The server closes the stream right after the first terminal event. Stop reading and do not reconnect.
- Events are stored, so the stream replays from the start of the job. If you connect after the job has ended, you get its past events and the terminal event at once.
- While nothing happens, the server sends a `: heartbeat` comment line about every 15 seconds. Ignore lines that start with `:`.
- The connection can drop before the job ends (network, proxy, or server time limits). Reconnect after about 2 seconds and send `Last-Event-ID` with the last `id` you saw; the stream then sends only newer events. `Last-Event-ID` must be a non-negative integer, or the request returns `400`.
- Errors before the stream opens (`401`, `404`, `429`) use the normal JSON error body, not SSE.

The browser `EventSource` API cannot send an `Authorization` header, so use a server-side client. This Node.js 22+ example needs no extra packages. It resolves with the terminal event and resumes after a dropped connection:

```js
export async function waitForJob(baseUrl, apiKey, jobId) {
  let lastEventId = "0";
  for (;;) {
    const response = await fetch(`${baseUrl}/api/v1/jobs/${jobId}/events`, {
      headers: {
        Authorization: `Bearer ${apiKey}`,
        Accept: "text/event-stream",
        "Last-Event-ID": lastEventId,
      },
    });
    if (!response.ok) throw new Error(`Job events: HTTP ${response.status}`);

    const decoder = new TextDecoder();
    let buffer = "";
    for await (const chunk of response.body) {
      buffer += decoder.decode(chunk, { stream: true }).replaceAll("\r\n", "\n");
      let end;
      while ((end = buffer.indexOf("\n\n")) !== -1) {
        const frame = buffer.slice(0, end);
        buffer = buffer.slice(end + 2);
        const fields = {};
        for (const line of frame.split("\n")) {
          const colon = line.indexOf(":");
          if (colon > 0) fields[line.slice(0, colon)] = line.slice(colon + 1).trimStart();
        }
        if (!fields.data) continue; // retry hint or heartbeat
        lastEventId = fields.id ?? lastEventId;
        const event = JSON.parse(fields.data);
        if (event.type !== "job.status") return event; // terminal event
      }
    }
    await new Promise((resolve) => setTimeout(resolve, 2_000)); // dropped: resume
  }
}
```

After `job.completed`, fetch the page or `revisionId` as shown in step 5. After `job.failed`, read `failure` and stop.

### Option B: poll the job

Poll from a server with the same organization key. Start after the `Retry-After` delay, then use `meta.pollAfterSeconds` from the job response when present (or wait about two seconds).

```sh
curl "$BASE_URL/api/v1/jobs/$JOB_ID" \
  --header "Authorization: Bearer $ANSWER_PAGE_API_KEY"
```

Read `data.status`. When it is `completed`, fetch the page; when `failed`, read the safe `data.failure` and stop. `unknown` can mean an ambiguous provider outcome: do **not** blindly resubmit it or assume it failed.

## 5. Read the answer

```sh
curl "$BASE_URL/api/v1/pages/$PAGE_ID" \
  --header "Authorization: Bearer $ANSWER_PAGE_API_KEY"
```

A ready page returns `data.markdown`, `data.resources`, `data.revision.id`, `meta.publicUrl`, and `meta.formats`. Before it is ready, `data.markdown` and `data.revision` are `null` and `meta.formats` is absent. `meta.formats.markdown` and `meta.formats.pdf` are public file paths, relative to `BASE_URL` like `meta.publicUrl`: the current revision as Markdown and as a PDF. Anyone with the path can download them without a key. To retrieve an immutable revision as its **exact stored Markdown**, use:

```sh
curl "$BASE_URL/api/v1/pages/$PAGE_ID/revisions/$REVISION_ID" \
  --header "Authorization: Bearer $ANSWER_PAGE_API_KEY" \
  --header 'Accept: text/markdown'
```

Send `Accept: application/pdf` instead to get that exact revision as a PDF. Without either `Accept` header, the revision endpoint returns JSON. History is at `GET /api/v1/pages/{pageId}/history?limit=20&cursor=...` (newest first). The public page at `BASE_URL + meta.publicUrl` needs no API key, but private job details, history, metadata, and revision endpoints do.

## 6. Optional: receive signed completion webhooks

Expose a public HTTPS endpoint on your server. Register it using the **organization** key:

```sh
curl --request POST "$BASE_URL/api/v1/webhook-endpoints" \
  --header "Authorization: Bearer $ANSWER_PAGE_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://your-service.example/webhooks/answer-page"}'
```

The `201` response includes `data.signingSecret` beginning with `whsec_`. Save it per endpoint before accepting traffic. `GET /api/v1/webhook-endpoints` lists endpoints but does **not** repeat their secrets; `DELETE /api/v1/webhook-endpoints/{endpointId}` removes one. Registration of the same normalized URL returns the same endpoint and secret.

Webhook events have `id`, `type`, `createdAt`, and `data`. Types are `job.completed`, `job.failed`, and `job.action_required`; `data` includes `jobId`, `pageId`, and your original `metadata`, plus a revision ID for completion or safe failure for a failed job. They do **not** contain the full Markdown: use your organization key to fetch the page or revision after a completed event.

**Verify the signature before parsing or acting on the body.** The request has `Answer-Page-Signature: t=UNIX_SECONDS,v1=HEX_HMAC_SHA256` and `Webhook-Id`. The HMAC-SHA256 input is the ASCII timestamp, a period, then the **raw request body bytes**, keyed with that endpoint's `signingSecret`. For example, in Node.js (with `rawBody` as a `Buffer`):

```js
import { createHmac, timingSafeEqual } from "node:crypto";

function verifiedWebhook(header, rawBody, signingSecret) {
  const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header ?? "");
  if (!match || Math.abs(Date.now() - Number(match[1]) * 1000) > 300_000) {
    return false;
  }
  const expected = createHmac("sha256", signingSecret)
    .update(`${match[1]}.`)
    .update(rawBody)
    .digest();
  const received = Buffer.from(match[2], "hex");
  return timingSafeEqual(expected, received);
}
```

Reject invalid or stale requests, and deduplicate verified events by `id` (and/or `Webhook-Id`) before processing. Persist the event and return `2xx` promptly; handle slower work separately. Non-`2xx` responses and timeouts may be retried, so assume at-least-once delivery. Keep polling or the event stream as a fallback if a webhook is delayed or exhausted its retries. Never log the signing secret or raw API key.

## Other operations and failures

- To revise a page, `POST /api/v1/pages/{pageId}/mutations` with `Idempotency-Key` and JSON `{"instruction":"Add a short summary","expectedRevisionId":"CURRENT_REVISION_UUID","depth":"quick","metadata":{}}`. `depth` is optional; without it, the revision uses the page's own depth. The agent researches the instruction and rewrites the whole page. It returns `202`; poll or stream the new `meta.job.id`. A stale revision or concurrent edit returns `409` rather than overwriting newer work.
- To build a new page from an existing one and leave the original unchanged, `POST /api/v1/pages` with `Idempotency-Key` and JSON `{"mode":"fork","pageId":"PAGE_UUID","revisionId":"REVISION_UUID","instruction":"What about in winter?","depth":"quick","metadata":{}}`. The source must be one of your organization's pages; any of its revisions works. `depth` is optional and defaults to the source page's. It returns `202` with a new pending page, like research. **A fork's instruction is public**: its page lists it after the source page's questions. A revision's instruction stays private.
- To restore an earlier revision, `POST /api/v1/pages/{pageId}/restores` with JSON `{"revisionId":"OLD_REVISION_UUID","expectedRevisionId":"CURRENT_REVISION_UUID"}`. It creates a new immutable revision and returns `201`.
- Failed API calls return `{ "data": null, "error": { "code": "...", "message": "..." }, "meta": { "traceId": "..." } }`. `401` means the organization key is missing or invalid; `404` can hide another organization's data; `409` means a conflict; `422` is a validation error; `429` may include `Retry-After`. Do not retry all errors with a fresh idempotency key.
- JSON writes require `Content-Type: application/json`. Keep the full request body at or below 300 KiB. Key-based rate-limit headers may include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`.

This is a public integration guide, not an authentication credential. Your API key and per-webhook signing secret have different jobs; do not exchange one for the other.
