# Record reactions

`POST https://stylor.ai/api/v1/shoppers/me/signals`

- Authentication: public API key, sent as `Authorization: Bearer sgpt-pk-…`
- Rate-limit resource: `api.v1.shoppers`
- Demo key: accepted
- Web page: https://stylor.ai/guides/rest/v1/record-shopper-signals

Records what the shopper did, and learns from it in the same call: the
next feed, ranking or search already knows. Send reactions as they
happen, up to 50 at a time.

## Kinds and weights

| kind | weight | needs |
|---|---|---|
| `purchase` | 5 | `itemId` |
| `like` | 3 | `itemId` |
| `cart_add` | 3 | `itemId` |
| `look_like` | 3 | `itemIds`, the pieces of a look (up to 8) |
| `said` | 2 | `text`, something they said about their taste ("minimal earthy workwear") |
| `look_more` | 2 | `itemId` |
| `click` | 1 | `itemId` |
| `cart_remove` | −1 | `itemId` |
| `dislike` | −3 | `itemId` |
| `look_dislike` | −3 | `itemIds` |

A look is learnt as an outfit, and each of its pieces at 0.4 of the
look's weight. Reactions fade with a 60-day half-life. A signal of an
unknown kind, or without what its kind needs, is skipped.

## Taking one back

`{ "kind": "undo", "undoes": "like", "itemId": "…" }` deletes the
latest such reaction and relearns the taste without it, so a mistaken
like stops pulling. Undos in a call run before its other signals, so
"like, then not for me" in one call ends as not for me.

Without a token, a profile is made for the first reaction and its
`token` is returned. Send reactions one call at a time until you hold
it: three quick likes sent at once on a new shopper make three
profiles.

## Example request

```bash
curl -X POST "https://stylor.ai/api/v1/shoppers/me/signals" \
  -H "Authorization: Bearer $STYLOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "signals": [
    {
      "kind": "like",
      "itemId": "701e5cee-41da-4e91-a688-69e8c8dd5fe3"
    },
    {
      "kind": "click",
      "itemId": "a342cbe3-965b-4055-8ac3-20f63103cade"
    },
    {
      "kind": "look_dislike",
      "itemIds": [
        "6a6dca06-2e8c-4cb8-b178-4c680c72c385",
        "35a725c8-23fb-4009-a52a-2a778d2e504a"
      ]
    }
  ]
}'
```

## Headers

- `X-Shopper-Token` (string): The shopper's profile token, from `auth/start`, `auth/verify` or any call that made a profile. Optional here. [example `"eyJ2IjoxLCJ0eXAiOiJwcm9maWxlIiwicCI6IjRmM2Ei…"`]

## Request body

Content type `application/json`, required.

- `signals` (array<ShopperSignal>, required): The reactions, oldest first. Only the first 50 are read. [max items 50]
  - `kind` (string, required): What happened. See the table above. [one of `"purchase"`, `"like"`, `"cart_add"`, `"look_like"`, `"said"`, `"look_more"`, `"click"`, `"cart_remove"`, `"dislike"`, `"look_dislike"`, `"undo"`]
  - `itemId` (string): The product, for a product reaction (and for undoing one).
  - `itemIds` (array<string>): The pieces of a look, for `look_like` and `look_dislike` (and for undoing one). Up to 8 are read.
  - `text` (string): For `said`, their words. Up to 280 characters are kept.
  - `occasion` (string): What it was for ("work", "wedding"), when you know. Up to 40 characters.
  - `undoes` (string): For `undo`, the kind taken back (`like`, `dislike`, `look_like`, …).

### Examples

#### Likes

A like, a click and a look passed on.

```json
{
  "signals": [
    {
      "kind": "like",
      "itemId": "701e5cee-41da-4e91-a688-69e8c8dd5fe3"
    },
    {
      "kind": "click",
      "itemId": "a342cbe3-965b-4055-8ac3-20f63103cade"
    },
    {
      "kind": "look_dislike",
      "itemIds": [
        "6a6dca06-2e8c-4cb8-b178-4c680c72c385",
        "35a725c8-23fb-4009-a52a-2a778d2e504a"
      ]
    }
  ]
}
```

#### In their words

Taste the shopper put in words, for an occasion.

```json
{
  "signals": [
    {
      "kind": "said",
      "text": "minimal earthy workwear",
      "occasion": "work"
    }
  ]
}
```

#### Take a like back

Deletes the latest like of a product.

```json
{
  "signals": [
    {
      "kind": "undo",
      "undoes": "like",
      "itemId": "701e5cee-41da-4e91-a688-69e8c8dd5fe3"
    }
  ]
}
```

## Responses

### 200

Recorded and learnt.

Content type `application/json`:

- `recorded` (integer, required): Reactions recorded.
- `undone` (integer): Reactions taken back. Present when the call held undos.
- `token` (string): Only when no token was sent and this call made the profile. Keep it.

Example:

```json
{
  "recorded": 3
}
```

## Errors

Every error is a JSON object with an `error` string. Match on the status code; the message is written for people.

| Status | Message | When |
| --- | --- | --- |
| 400 | Request body must be valid JSON. | The request body could not be parsed as JSON. |
| 400 | Request body must be a JSON object. | The request body parsed to something other than a JSON object. |
| 401 | That shopper token is not valid here. Start a profile or sign in again. | `X-Shopper-Token` was sent but cannot be opened with this key. |
| 403 | Shopper profiles are switched off for this organization. | The organization turned shopper profiles off. |
| 404 | No such profile. | The profile was deleted while the reactions were being learnt. |
| 422 | Nothing to record. | No signal had a known kind with what it needs, or none could be recorded. |

### Shared authentication, quota and rate-limit errors

| Status | Message | When |
| --- | --- | --- |
| 401 | API key missing. | The `Authorization` header is absent, is not `Bearer <key>`, or the key does not have three dash-separated segments. |
| 401 | Authentication failed: public key is invalid or not recognized. | A public-key endpoint was called with a key that does not exist. |
| 401 | Authentication failed: private key provided instead of a public key. | A public-key endpoint was called with an `sgpt-sk-…` key. |
| 401 | Authentication failed: token is not recognized. | A private-key endpoint was called with a key whose lookup segment does not exist. |
| 401 | Authentication failed: private key is invalid. | A private-key endpoint was called with a key whose secret does not match. |
| 401 | Authentication failed: public key provided instead of a private key. | A private-key endpoint was called with an `sgpt-pk-…` key. |
| 401 | Authentication failed: bearer token format is invalid. | A private key was sent without its `::` lookup segment. |
| 401 | Authentication failed: API key could not be read. Regenerate the key. | The key's embedded organization data could not be decrypted or parsed. |
| 403 | This key does not have the "<scope>" scope. | A private-key endpoint was called with a key whose scopes do not include the one the endpoint needs. Nothing is counted against your quota. See Authentication. |
| 403 | This key's scopes do not cover this endpoint. | A key with scopes called a private endpoint that no scope grants yet. Give the key every scope to reach it. |
| 403 | Demo mode is not available for private-key routes. Demos are only supported with public API keys. | The demo key was sent to a private-key endpoint. |
| 403 | Demo mode is not supported on this API route. Check the documentation for available demo endpoints. | The demo key was sent to a public-key endpoint that opts out of demo mode. |
| 403 | No active subscription for this organization. | The organization that owns the key has no active subscription. |
| 403 | No quota found for organization "<organizationId>". | The organization has no quota record for the current billing period. |
| 403 | No entitlement for "<resource>". | The plan's quota template has no entry for this endpoint. |
| 403 | Access denied to "<resource>". | The plan's quota template turns this endpoint off. |
| 429 | Monthly quota exceeded for "<resource>" (<quota>/<quota> used). | The billing-period quota for this endpoint is exhausted. `Retry-After` gives the seconds until the period resets. |
| 429 | RPM limit exceeded for "<resource>" (<used>/<limit> used). | More than the allowed requests per minute were sent. `Retry-After` is 60. |
| 500 | Internal server error. | An unexpected failure on the server. The message is always this string; details are logged, never returned. Quota consumed by the request is refunded, as it is for every 5xx response. |
