# API

Sign people up and look them up from your own server.

## API

Sign people up and look them up from your own server, with the project's secret key. The API works with every way. With `own_site_api`, it comes first in **Build › Where it lives**.

### Get the secret key

- `GET /projects/:id/setup/site/api`: get the keys and code examples. *analytics*
  - `public_key`: the project's `embed_key`. It goes in the browser, in the embed.
  - `secret_key_set`: whether a secret key exists. `secret_key_hint`: its last 4 characters. `secret_key_created_at`: when it was made.
  - `api_url`: the API's base address.
  - `examples`: the two calls, each with `language` (`curl`, `JavaScript` or `Python`) and `code`.
  - `live_check`: the newest sign-up or refused site, as in `GET /projects/:id/setup`.
- `POST /projects/:id/setup/site/api/key`: make the secret key, or replace it. *manage*
  - Returns `secret_key` once. Hunchwire keeps only a digest, so it can't show the key again.
  - A new key replaces the old one at once. Calls with the old key fail until the server has the new one.
  - Put it in the founder's server environment as `HUNCHWIRE_SECRET_KEY`. Never put it in a page or a repository.
  - Only make or replace it when the founder asks.

### Authenticate

Send the secret key on every call, from a server:

```
Authorization: Bearer sk_…
Content-Type: application/json
```

The base address is `https://app.hunchwire.com/v1`. Hunchwire refuses a call that carries a browser `Origin` header. In a browser, use the embed.

### The person

A person has one contact: an `email`, or a `phone` in international format (`+14165550123`). Send exactly one, and `first_name` if you have it. Hunchwire never emails or texts a number: your product does the texting. Every person you get back, from a call or a webhook, has `email`, `phone` and `first_name`. The contact you didn't use is `null`.

```json
{ "id": 4812, "email": "maya@fernwood.studio", "phone": null, "first_name": "Maya", "list": null,
  "state": "waiting", "place": 148, "code": "k7q2m9xa", "link": "https://tryloop.app/?ref=k7q2m9xa",
  "friends": 2, "pending_friends": 0, "referred_by": 4790, "access": { "granted": false, "via": null, "at": null },
  "rewards": [], "joined_product_at": null, "signed_up_at": "2026-10-11T14:02:11Z", "confirmed": false }
```

- `id`: the person's id in Hunchwire. Store it.
- `state`: `waiting`, `invited` or `removed`, as on **People**.
- `place`: their place in line while the line moves, else `null`.
- `code`: their referral code. `link`: their link to share. It uses the project's referral `link` when one is set.
- `friends`: friends who joined through their link and count, plus people their link let in while waiting. A friend counts once both have confirmed (see Referrals).
- `pending_friends`: friends who confirmed and count the moment this person confirms.
- `referred_by`: the id of whoever brought them, or `null`. You never get anyone else's contact.
- `access`: whether they're in. `granted` is true or false. `via` is `invite` (an invite exists), `referral` (a friend's link let them in) or `friends` (they brought enough friends). `at` is when.
- `rewards`: what they earned through referrals, each with `key`, `label`, `reached_at` and `given_at`.
- `joined_product_at`: when your product reported they joined it, or `null`.
- `signed_up_at`: their first sign-up. `confirmed`: whether they confirmed their email, or texted you from their number.

### Sign people up and look them up

- `POST /v1/people`: sign someone up. *secret key*
  ```
  {"email": "maya@fernwood.studio", "first_name": "Maya", "ref": "k7q2m9xa", "visitor_id": "6f1c…"}
  ```
  - Takes `email` or `phone`. Optional: `first_name` (up to 50 characters), `ref`, `list`, `consent_text` (up to 500 characters), `confirmed`, `form` (default `waitlist`), and the visit: `visitor_id`, `page_url`, `referrer`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`.
  - `ref` is a friend's code. `list` works like the embed's `data-list`. `consent_text` is the consent wording your form showed.
  - For a number, show and send consent wording like: "By continuing, you agree to get messages from Loop at this number. Message and data rates may apply. Reply STOP to opt out."
  - Send `"confirmed": true` once the person has texted you from the number. Their first text proves it's theirs. Only a number takes it. An email confirms by the link Hunchwire sends.
  - On a number, `confirmed` true means the person texted you from that number.
  - Returns the person: 201 when new, 200 when the contact is already on the list.
  - `ref_status` says what happened to `ref`: `none` (none sent), `counted` (recorded as their referrer; it counts once both have confirmed), `held` (recorded, but held for review), `already_on_list` (only a first sign-up can be referred), `own_code` or `unknown`.
  - `invite`: when a friend's link let them in, their invite's `id`, `url` and `expires_at`, else `null`. Welcome them right away, then report the join.
  - A number already on the list, sent again with `ref` and `confirmed`, can be let in through that link. An email already on the list is let in only when they click the link Hunchwire emails them.
  - Without `consent_text`, Hunchwire records the project's own consent line for an email sign-up.
  - It runs the form's spam checks. A throwaway or unreachable email domain is refused and listed under **People › Blocked**.
- `GET /v1/people/:id`: get a person by id. *secret key*
- `GET /v1/people?email=maya%40fernwood.studio`: get a person by contact. *secret key*
  - For a phone number, send `?phone=%2B14165550123`. A leading space is read as `+`.
  - Never put a contact in the path.
- `PATCH /v1/people/:id` with `{"first_name": "Maya"}`: set a person's first name. It's the only field you can change. *secret key*
- `POST /v1/people/:id/joined_product`: tell Hunchwire your product let this person in. *secret key*
  - Use it when people join without an invite link, such as in a chat.
  - It uses up their open invite, if they have one.
  - Returns the person with `joined_product_at` set. A second call changes nothing.
- `POST /v1/invites/redeem` with `{"code": "…"}` or `{"token": "…"}`: tell Hunchwire an invited person joined your product. *secret key*
  - Send `code`, the end of their invite link, or `token`, the `hw_token` your app already checked (see Let people in).
  - Returns `person` (`null` when the invite went to someone not on the list) and `invite`, with `id`, `email`, `phone` (one is `null`), `state` (`joined`) and `redeemed_at`.
  - Errors: 409 `used` when it was already redeemed. 410 `expired` when the invite (14 days) or the token (10 minutes) expired. 404 `not_found` for a code or token that isn't this project's.
- `DELETE /v1/people/:id`: delete a person's data in this project. *secret key*
  - Use it when someone asks your product to delete them, or texts STOP.
  - It's the same as **Delete** on their profile. Hunchwire never re-adds them. It emails them again only if they sign up again and confirm.
  - Returns 204.

### Limits

| Calls | Limit |
| --- | --- |
| Sign-ups | 30 a minute per project |
| Lookups and updates | 300 a minute per project |

### Errors

An error returns `{"error": "<code>", "message": "…"}`.

| Status | `error` | When |
| --- | --- | --- |
| 401 | `unauthorized` | No secret key, or not this project's |
| 403 | `browser_origin` | The call came from a browser |
| 403 | `not_live` | The project isn't live yet |
| 404 | `not_found` | No one with that id or contact, or no such invite |
| 409 | `used` | The invite was already redeemed |
| 410 | `expired` | The invite or token expired |
| 422 | `contact_required`, `two_contacts` | Send exactly one of `email` or `phone` |
| 422 | `invalid_email` | The email isn't valid |
| 422 | `invalid_phone` | The number has no country code, or isn't a mobile number's length |
| 422 | `confirm_by_link` | `confirmed` was sent for an email |
| 422 | `invalid_first_name`, `invalid_list`, `unknown_form` | A field is out of range |
| 422 | `disposable`, `no_mail` | The email domain is refused |
| 429 | `rate_limited` | Too many calls. Wait a minute |
