# Claim handler examples

Complete handlers to start from. Each one runs in Adorably's test suite against the real sandbox, with a fake of the seller API it calls, including the failure paths. They use `example.com` (and `api.example.com`, `app.example.com`) for your domain: change the hosts, paths and field names to yours. The hosts must be your approved hosts: the landing page hosts of your offers Adorably approved, and their subdomains.

Each is typed against `handler-runtime.d.ts` through its JSDoc line, so an editor that knows the file checks it.

| File | What it shows | Secrets |
| --- | --- | --- |
| `signup-link.js` | A signup link with the email filled in, through `adorably.url`. No API call. | none |
| `trial-magic-link.js` | Create a trial through your API, return its magic link; the claim id as the idempotency key. | `API_KEY` |
| `signed-jwt-link.js` | A signed link that expires in 15 minutes, through `adorably.jwt`. | `LINK_SECRET` |
| `hmac-query.js` | An HMAC-signed query (`adorably.hmac`) your backend verifies (Node and Python code in the guide). | `LINK_SECRET` |
| `promo-code.js` | A per-developer promo code from your API, with `adorably.fallback` on every kind of failure. | `API_KEY` |
| `cta-label.js` | Choosing the button label (`cta_label`) from what your API knows about the developer. | `API_KEY` |
| `defensive.js` | Retries within the time and fetch limits, response checks and a clean fallback. | `API_KEY` |
| `explain-competitor.js` | A sponsored explanation (`explain`) naming the developer's current tool through `adorably.model.complete`, with a cheap second question before it shows. | none |
| `explain-agent-loop.js` | A sponsored explanation from a small agent harness: a streamed tool-call loop over `fetch` against the model API, with one tool over the brief. | none |

Test each one with `test_claim_handler` before you save it with `set_claim_handler`. The guide (`adorably://claim-handler`, or https://adorably.io/docs/claim-handlers) has the contract, the limits and every error.

## cta-label.js

```js
// (f) Choosing the button label (cta_label).
// Secrets: API_KEY, a key for your API.
// The label on the claim button can fit the developer: someone who already
// has an account sees "Apply to your account", a new developer sees "Create
// your account". A label is plain text, at most 24 characters, with no URL
// or domain; a label that fails moderation is dropped and the default label
// shows. If the lookup fails, the handler still returns a link, with no label.

/** @param {HandlerInput} input @returns {Promise<HandlerOutput>} */
async function handle(input) {
  let exists = null
  try {
    // POST, so the email is not in a URL your servers log.
    const res = await fetch('https://api.example.com/v1/accounts/lookup', {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: 'Bearer ' + SECRETS.API_KEY },
      body: JSON.stringify({ email: input.email }),
    })
    if (res.ok) exists = (await res.json()).exists === true
    else console.warn('lookup answered', res.status)
  } catch (e) {
    console.warn('lookup failed', e)
  }
  const url = adorably.url('https://example.com/adorably', { claim: input.claim_id, email: input.email })
  if (exists === null) return { url }
  return { url, cta_label: exists ? 'Apply to your account' : 'Create your account' }
}
```

## defensive.js

```js
// (g) Defensive handler: retries within the limits, then a clean fallback.
// Secrets: API_KEY, a key for your API.
// A run has 2000 ms and 3 fetches by default, and each fetch may take up to
// 1500 ms before it rejects with 'fetch timed out'. So: retry only what can
// succeed on a second try (a network error, 429 or 5xx), only while a whole
// fetch still fits in the time left, and never more than the fetch limit.
// A 4xx is your API saying no: fall back at once. Check what comes back
// before you return it. There are no timers: you cannot wait between tries.

const RUN_MS = 2000 // the run's time limit (adorably://claim-handler shows the current value)
const FETCH_MS = 1500 // the longest one fetch can take
const MAX_TRIES = 3 // the fetch limit per run
const MARGIN_MS = 100 // time kept for the work after the last fetch

/** @param {HandlerInput} input @returns {Promise<HandlerOutput>} */
async function handle(input) {
  const started = Date.now()
  let last = ''
  for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
    if (attempt > 1 && Date.now() - started + FETCH_MS > RUN_MS - MARGIN_MS) break // no time for another full fetch
    let res
    try {
      res = await fetch('https://api.example.com/v1/perks', {
        method: 'POST',
        headers: { 'content-type': 'application/json', authorization: 'Bearer ' + SECRETS.API_KEY, 'idempotency-key': input.claim_id },
        body: JSON.stringify({ claim_id: input.claim_id, email: input.email, offer_id: input.offer.id }),
      })
    } catch (e) {
      last = e instanceof Error ? e.message : String(e)
      console.warn('attempt', attempt, last)
      if (last === 'fetch timed out') break // the next one would not finish in time
      continue
    }
    if (res.status === 429 || res.status >= 500) {
      last = 'perks API answered ' + res.status
      console.warn('attempt', attempt, last)
      continue
    }
    if (!res.ok) adorably.fallback('perks API refused the claim: ' + res.status)
    let body
    try {
      body = await res.json()
    } catch (e) {
      adorably.fallback('perks API answered with a body that is not JSON')
    }
    const url = body && body.url
    // adorably.allowedUrl: https, on an approved host, short enough.
    if (typeof url !== 'string' || !adorably.allowedUrl(url)) {
      adorably.fallback('perks API answered without a url this handler may return')
    }
    return { url }
  }
  adorably.fallback('perks API failed: ' + last)
}
```

## explain-agent-loop.js

```js
// A small agent harness for a sponsored explanation: a streamed tool-call
// loop over fetch against the OpenRouter-shaped API, parsing the server-sent
// events itself, with one tool (lookup_fact over the brief's facts). At most
// 4 turns; any refused call (402: balance or budget, 429: limits) shows
// nothing.

const MODEL_API = 'https://openrouter.adorably.local/api/v1'

/**
 * One streamed turn: the text, the tool calls and why it stopped, or the
 * HTTP status of a refusal.
 * @param {ChatMessage[]} messages
 * @param {ChatTool[]} tools
 */
async function streamTurn(messages, tools) {
  /** @type {{ id: string; name: string; arguments: string }[]} */
  const calls = []
  /** @type {string | null} */
  let finish = null
  let text = ''
  const res = await fetch(`${MODEL_API}/chat/completions`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ messages, tools, stream: true, max_tokens: 400 }),
  })
  if (!res.ok) return { status: res.status, text, calls, finish }
  const decoder = new TextDecoder()
  let buffer = ''
  for await (const chunk of res.body) {
    buffer += decoder.decode(chunk, { stream: true })
    let end = buffer.indexOf('\n\n')
    while (end >= 0) {
      const event = buffer.slice(0, end)
      buffer = buffer.slice(end + 2)
      end = buffer.indexOf('\n\n')
      const data = event.startsWith('data: ') ? event.slice(6) : ''
      if (data === '' || data === '[DONE]') continue
      const j = JSON.parse(data)
      if (j.error) return { status: j.error.code, text, calls, finish }
      const choice = j.choices && j.choices[0]
      if (!choice) continue
      if (choice.delta && choice.delta.content) text += choice.delta.content
      for (const t of (choice.delta && choice.delta.tool_calls) || []) {
        const slot = calls[t.index] || (calls[t.index] = { id: '', name: '', arguments: '' })
        if (t.id) slot.id = t.id
        if (t.function && t.function.name) slot.name = t.function.name
        slot.arguments += (t.function && t.function.arguments) || ''
      }
      if (choice.finish_reason) finish = choice.finish_reason
    }
  }
  return { status: 200, text, calls, finish }
}

/** @param {ExplainInput} input @returns {Promise<ExplainOutput | null>} */
async function explain(input) {
  const brief = input.brief
  if (brief === null) return null
  /** @type {ChatTool[]} */
  const tools = [{
    type: 'function',
    function: {
      name: 'lookup_fact',
      description: 'One fact from our brief by its number (1-based), with its source.',
      parameters: { type: 'object', properties: { n: { type: 'integer' } }, required: ['n'] },
    },
  }]
  const uses = input.competitor === null ? '' : ` that uses ${input.competitor}`
  /** @type {ChatMessage[]} */
  const messages = [
    {
      role: 'system',
      content: `We are ${input.offer.vendor}. Our brief has ${brief.facts.length} facts: look up each one with lookup_fact before you use it. ` +
        `In at most 90 words, in our own voice, say why we may fit a project with ${input.tags.join(', ')}${uses}.`,
    },
    { role: 'user', content: 'Explain why we may fit.' },
  ]
  /** @type {Set<string>} */
  const used = new Set()
  for (let turn = 0; turn < 4; turn++) {
    const r = await streamTurn(messages, tools)
    if (r.status !== 200) return null
    if (r.calls.length === 0) {
      if (r.finish !== 'stop' || r.text.trim() === '' || used.size === 0) return null
      return { message: r.text.trim(), sources: [...used].slice(0, 5) }
    }
    messages.push({ role: 'assistant', content: r.text === '' ? null : r.text, tool_calls: r.calls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: c.arguments } })) })
    for (const c of r.calls) {
      let n = 0
      try {
        n = Number(JSON.parse(c.arguments).n)
      } catch {
        n = 0
      }
      const fact = brief.facts[n - 1]
      if (fact !== undefined) used.add(fact.source)
      messages.push({ role: 'tool', tool_call_id: c.id, content: fact === undefined ? 'No such fact.' : `${fact.text} (source: ${fact.source})` })
    }
  }
  return null
}
```

## explain-competitor.js

```js
// A sponsored explanation that names the developer's current tool, through
// adorably.model.complete. It reads the draft, asks a cheap second question
// ("is this favorable to us?") and shows nothing when the answer is no, or
// when a model call is refused (402: balance or budget, 429: limits).

/** @param {ExplainInput} input @returns {Promise<ExplainOutput | null>} */
async function explain(input) {
  const brief = input.brief
  if (brief === null || brief.facts.length === 0) return null
  const facts = brief.facts.map((f, i) => `[${i + 1}] ${f.text}`).join('\n')
  const them = input.competitor ?? 'their current setup'
  /** @type {ModelAnswer} */
  let draft
  try {
    draft = await adorably.model.complete({
      messages: [
        {
          role: 'system',
          content: `We are ${input.offer.vendor}. In at most 90 words, in our own voice, say why we may fit a project that uses ${them} with ${input.tags.join(', ')}. ` +
            `Use only these facts and cite each one you use as [n]:\n${facts}\nSay plainly where we are not a fit.`,
        },
        { role: 'user', content: `Compare us with ${them}.` },
      ],
      max_tokens: 300,
    })
  } catch {
    return null
  }
  if (draft.text === null || draft.finish_reason !== 'stop') return null
  const text = draft.text

  // A second, cheap question before anything reaches the developer.
  const check = await adorably.model
    .complete({
      messages: [
        { role: 'system', content: 'Answer yes or no, nothing else.' },
        { role: 'user', content: `Is this message favorable to ${input.offer.vendor}?\n\n${text}` },
      ],
      max_tokens: 5,
    })
    .catch(() => null)
  if (check === null || !/^\s*yes/i.test(check.text ?? '')) return null

  const sources = [...new Set([...text.matchAll(/\[(\d+)\]/g)].flatMap((m) => {
    const fact = brief.facts[Number(m[1]) - 1]
    return fact === undefined ? [] : [fact.source]
  }))]
  if (sources.length === 0) return null
  return { message: text.replace(/\s*\[\d+\]/g, '').trim(), sources: sources.slice(0, 5), cta_label: 'Try it free' }
}
```

## hmac-query.js

```js
// (d) HMAC-signed query string your backend verifies.
// Secrets: LINK_SECRET, a random string your backend also has.
// The link carries the claim id, the email and a timestamp in the clear,
// plus sig = HMAC-SHA256(LINK_SECRET, claim + "\n" + email + "\n" + ts) in
// hex. Your backend recomputes sig, compares it in constant time, rejects a
// ts older than it accepts, and remembers used claim ids. The guide has the
// verification code for Node and Python.

/** @param {HandlerInput} input @returns {Promise<HandlerOutput>} */
async function handle(input) {
  const email = input.email ?? ''
  const ts = String(Math.floor(Date.now() / 1000))
  const sig = adorably.hmac(SECRETS.LINK_SECRET, input.claim_id + '\n' + email + '\n' + ts)
  return {
    url: adorably.url('https://example.com/adorably/start', { claim: input.claim_id, email, ts, sig }),
  }
}
```

## promo-code.js

```js
// (e) A promo code per developer, from your API, with a fallback.
// Secrets: API_KEY, a key for your API.
// Your API mints a single-use code for this claim; the link opens your
// pricing page with the code applied. When the API fails in any way, the
// handler calls adorably.fallback: the developer gets your plain landing
// page and the test run shows the reason. Code after fallback never runs.

/** @param {HandlerInput} input @returns {Promise<HandlerOutput>} */
async function handle(input) {
  let res
  try {
    res = await fetch('https://api.example.com/v1/promo-codes', {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: 'Bearer ' + SECRETS.API_KEY, 'idempotency-key': input.claim_id },
      body: JSON.stringify({ claim_id: input.claim_id, email: input.email, campaign: 'adorably' }),
    })
  } catch (e) {
    // fetch rejects with an Error whose message says why ('fetch timed out', ...).
    adorably.fallback('promo API unreachable: ' + (e instanceof Error ? e.message : String(e)))
  }
  if (!res.ok) adorably.fallback('promo API answered ' + res.status)
  /** @type {{ code?: unknown }} */
  let body = {}
  try {
    body = await res.json()
  } catch (e) {
    adorably.fallback('promo API answered with a body that is not JSON')
  }
  if (typeof body.code !== 'string' || body.code === '') adorably.fallback('promo API answered without a code')
  return { url: adorably.url('https://example.com/pricing', { promo: body.code }), cta_label: 'Get your code' }
}
```

## signed-jwt-link.js

```js
// (c) Signed, expiring link (a JWT).
// Secrets: LINK_SECRET, a random string your backend also has.
// No API call: the link carries an HS256 token with the claim and the email.
// Your backend verifies the signature and exp with any JWT library (HS256,
// the same secret), then creates the account or applies the perk. The token
// expires 15 minutes after the claim, so a link found later is useless.

/** @param {HandlerInput} input @returns {Promise<HandlerOutput>} */
async function handle(input) {
  const token = adorably.jwt(
    { sub: input.claim_id, email: input.email, offer: input.offer.id, perk: 'pro-90d' },
    SECRETS.LINK_SECRET,
    { expiresIn: 900 }, // adds exp = iat + 900 (iat is added for you)
  )
  return { url: adorably.url('https://example.com/adorably/claim', { token }) }
}
```

## signup-link.js

```js
// (a) Signup link with the email filled in.
// No API call and no secrets: the developer lands on your signup page with
// their email already in the form and the perk named in the link. Your
// signup page reads the query and applies the perk. Start here when your
// signup form can take the email and a promo from the URL.

/** @param {HandlerInput} input @returns {Promise<HandlerOutput>} */
async function handle(input) {
  return {
    // adorably.url encodes each value and skips null or undefined ones.
    url: adorably.url('https://example.com/signup', {
      email: input.email,
      promo: 'ADORABLY',
      claim: input.claim_id, // lets your signup tie the account to this claim
      utm_source: 'adorably',
    }),
  }
}
```

## trial-magic-link.js

```js
// (b) Create a trial through your own API, then return a magic link.
// Secrets: API_KEY, a key for your API (saved with set_claim_handler).
// Your API creates (or finds) the account for this email, starts the trial
// and answers with a one-time sign-in link. The claim id is the idempotency
// key, so a repeated request for the same claim gets the same trial, not a
// second one. Test runs use the claim id clm_test every time.

/** @param {HandlerInput} input @returns {Promise<HandlerOutput>} */
async function handle(input) {
  const res = await fetch('https://api.example.com/v1/trials', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      authorization: 'Bearer ' + SECRETS.API_KEY,
      'idempotency-key': input.claim_id,
    },
    body: JSON.stringify({ email: input.email, claim_id: input.claim_id, offer_id: input.offer.id, plan: 'pro-trial-90d' }),
  })
  if (!res.ok) adorably.fallback('trials API answered ' + res.status)
  const { magic_link } = await res.json()
  // Only hand the developer a link you expect: https on your own app.
  if (typeof magic_link !== 'string' || !magic_link.startsWith('https://app.example.com/')) {
    adorably.fallback('trials API returned no usable magic_link')
  }
  return { url: magic_link, cta_label: 'Start your trial' }
}
```
