# Inzendingen

Een inzending is de set antwoorden van één bezoeker op één formulier. Verstuur haar vanaf je server, nooit vanuit de browser: met de `forms:write`-sleutel die ervoor nodig is, kan iemand mail versturen namens de merchant (zie [Houd forms:write-sleutels op je server](./index#houd-formswrite-sleutels-op-je-server)).

## POST /v1/api/forms/{key}/submissions

Controleert de antwoorden tegen de gepubliceerde versie van het formulier, claimt de geüploade bestanden, slaat de inzending op, nummert haar en antwoordt met de bedankinstellingen. Vereist de scope `forms:write`. Het formulier moet actief en gepubliceerd zijn.

### Padparameters

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `key` | string | Ja | De key van het formulier, bijvoorbeeld `quote-request`. |

### Headers

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `Authorization` | string | Ja | `Bearer {forms_write_key}`. |
| `Content-Type` | string | Ja | `application/json`. |
| `X-Borkol-Visitor-Ip` | string | Nee | Het IP-adres van de bezoeker (IPv4 of IPv6). De limiet per uur telt per bezoekersadres, en de Turnstile-controle wordt ermee verstuurd. Ontbreekt hij of is het geen geldig IP-adres, dan wordt het adres van de verbinding (je server) gebruikt. Zie [Bezoekers-IP en de limiet per uur](./index#bezoekers-ip-en-de-limiet-per-uur). |
| `User-Agent` | string | Nee | Wordt bij de inzending opgeslagen (eerste 500 tekens) en in de inbox als browser getoond. Stuur de eigen `User-Agent` van de bezoeker door als je die daar wilt zien. |

### Bodyparameters

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `answers` | object | Ja | Antwoorden per veld-key; mag `{}` zijn. De waardetypes per veldtype staan onder [Veldtypes](./index#veldtypes). Onbekende keys, `heading`- en `paragraph`-velden en antwoorden op velden die door hun regels verborgen zijn, vallen zonder fout weg. |
| `locale` | string | Nee | De taal van de bezoeker, maximaal 16 tekens, bijvoorbeeld `nl`. Een taal die is ingeschakeld voor de winkel bepaalt de taal van `thank_you.message` en van de bevestigingsmail aan de bezoeker; anders geldt de standaardtaal van de winkel. |
| `files` | object | Nee | Bestandsantwoorden: veld-key naar een lijst uploadtokens van [`POST /v1/api/forms/{key}/uploads`](./uploads), bijvoorbeeld `{"photos": ["9b2f0c3e-..."]}`. Maximaal `max_files` tokens per veld. Verplichte bestandsvelden beantwoord je hier; een waarde voor een bestandsveld in `answers` wordt genegeerd. |
| `turnstile_token` | string | Voorwaardelijk | Het token van de Cloudflare Turnstile-widget. Verplicht als het formulier `settings.turnstile_site_key` teruggeeft; anders genegeerd. Werkt één keer: haal na elke `422` een nieuw token op. Zie [Turnstile](./index#turnstile). |
| `meta` | object | Nee | Paginagegevens die in de inbox van de merchant verschijnen. Alles wat hieronder niet staat, wordt genegeerd. |
| `meta.page_url` | string | Nee | De pagina waarop het formulier stond. Wordt alleen bewaard als het een absolute `http://`- of `https://`-URL van maximaal 2000 tekens is; anders valt hij weg. |
| `meta.referrer` | string | Nee | De pagina waar de bezoeker vandaan kwam. Zelfde regel als `page_url`. |
| `meta.utm` | object | Nee | Campagnewaarden, bijvoorbeeld `{"source": "google", "campaign": "spring"}`. Alleen stringwaarden; de eerste 10 keys van maximaal 200 tekens blijven bewaard en waarden worden ingekort tot 200 tekens. |
| `honeypot` | string | Nee | De waarde van een invoerveld dat mensen nooit zien. Elke andere waarde dan een lege string of `null`: het request wordt als bot behandeld (zie hieronder). |

```bash title="Request"
curl -X POST "https://api.borkol.com/v1/api/forms/quote-request/submissions" \
  -H "Authorization: Bearer {forms_write_key}" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "X-Borkol-Visitor-Ip: 203.0.113.24" \
  -d '{
    "locale": "en",
    "answers": {
      "services": ["website"],
      "pages": 8,
      "campaign": "spring",
      "name": "Ada Lovelace",
      "email": "ada@example.com",
      "consent": true
    },
    "files": {
      "brief": ["9b2f0c3e-5a1d-4f7e-8c2b-1d3e4f5a6b7c"]
    },
    "turnstile_token": "0.AbCdEf...",
    "meta": {
      "page_url": "https://example.com/quote",
      "referrer": "https://www.google.com/",
      "utm": { "source": "google" }
    },
    "honeypot": ""
  }'
```

```javascript title="Request (JS)"
// A Next.js route handler: the browser posts { locale, answers, files, turnstile_token } here, never to Borkol.
export async function POST(request) {
  const body = await request.json();
  // Only trust an IP header your own proxy or host sets.
  const visitorIp = request.headers.get("x-real-ip") ?? "";
  const res = await fetch("https://api.borkol.com/v1/api/forms/quote-request/submissions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.BORKOL_FORMS_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
      "X-Borkol-Visitor-Ip": visitorIp,
      "User-Agent": request.headers.get("user-agent") ?? "",
    },
    body: JSON.stringify({ ...body, meta: { page_url: request.headers.get("referer") ?? undefined } }),
  });
  return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
}
```

Status: `201 Created`. Het resultaat is verpakt in `data`.

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `number` | string of null | altijd | Het nummer van de inzending, bijvoorbeeld `INQ-0042`, uit de nummerreeks voor aanvragen van de winkel (standaard `INQ-` en een doorlopend nummer van vier cijfers). Alleen `null` bij een honeypot-hit. |
| `thank_you.message` | string of null | altijd | Het bedankbericht in de taal van de inzending, of `null` als de merchant er geen heeft geschreven. |
| `thank_you.redirect_url` | string of null | altijd | Een `https://`-adres om de bezoeker naartoe te sturen, of `null` om het bericht te tonen. |

```json title="Response"
{
  "data": {
    "number": "INQ-0042",
    "thank_you": {
      "message": "Thanks! We will get back to you soon.",
      "redirect_url": null
    }
  }
}
```

### Hoe een inzending wordt gecontroleerd

In deze volgorde:

1. Het formulier wordt opgezocht: `404` als het niet actief is of nooit is gepubliceerd.
2. **Honeypot.** Heeft `honeypot` een andere waarde dan een lege string of `null`, dan antwoordt de API meteen `201` met `{"number": null, "thank_you": {...}}`. Er wordt niets opgeslagen, er gaat geen mail of webhook uit, Turnstile wordt niet gecontroleerd, en de poging telt niet mee voor de limiet per uur. Vertel de bezoeker niets anders: de response lijkt met opzet op een succes.
3. **Limiet per uur.** De poging wordt geteld voor dit formulier en dit bezoekersadres (uit `X-Borkol-Visitor-Ip`). Boven de limiet van het formulier (de merchant stelt 1 tot 100 per uur in, standaard 5) antwoordt de API `429`. Elke getelde poging gaat van het budget af, ook pogingen die daarna met `422` stranden.
4. **Turnstile**, alleen als het formulier `settings.turnstile_site_key` teruggeeft. Het `turnstile_token` wordt bij Cloudflare gecontroleerd; een mislukte controle geeft `422` op `turnstile`. Vanaf hier is het token opgebruikt, wat er daarna ook gebeurt.
5. **Antwoorden.** Eerst wordt de vorm van de body gecontroleerd, daarna de antwoorden tegen de gepubliceerde versie: regels bepalen welke stappen en velden worden getoond, antwoorden op velden die door hun regels verborgen zijn vallen weg en zijn niet verplicht, verplichte zichtbare velden moeten zijn beantwoord, en elke waarde moet bij zijn type passen. Uploadtokens in `files` worden hier als lijst gecontroleerd (fouten op `files.{key}`).
6. **Bestanden.** Alleen als de antwoorden kloppen: elk token van een zichtbaar bestandsveld wordt geclaimd. Elk token moet bestaan, bij dit formulier en dit veld horen, ongebruikt en niet verlopen zijn, en één keer voorkomen; anders antwoordt de API `422` op `files.{key}`. Het claimen gebeurt in dezelfde transactie als het opslaan, dus een geweigerde inzending claimt niets: de bezoeker kan de antwoorden verbeteren en dezelfde tokens opnieuw sturen (met een nieuw Turnstile-token).
7. **Opgeslagen.** De zichtbare antwoorden (inclusief waarden van `hidden`-velden) worden opgeslagen met de versie, de taal en `meta`; de geclaimde bestanden worden aan de inzending gekoppeld. Het ruwe bezoekers-IP wordt nooit opgeslagen, alleen een HMAC-SHA256-hash ervan. De inzending wordt aan een contact gekoppeld als het e-mailadres overeenkomt met precies één contact van de winkel (hoofdletterongevoelig); er wordt nooit een contact aangemaakt.
8. **Gemeld** nadat het antwoord is opgeslagen: een e-mail aan de merchant, een bevestigingsmail aan de bezoeker (als het formulier die aan heeft staan en de bezoeker een e-mailadres gaf), een melding in het dashboard en de webhook [`form_submission.created`](/nl/api/webhooks/index). Als een van deze stappen mislukt, wordt dat voor de merchant vastgelegd; de `201` verandert nooit.

### Adresvelden en het werkgebied

Voor elk zichtbaar `address`-veld met opzoeken (`config.lookup: true`) zoekt de server de postcode en het huisnummer opnieuw op voordat hij het werkgebied controleert:

- **Gevonden:** de coördinaten van de treffer vervangen `lat` en `lon` als je er geen stuurde, of als de jouwe meer dan 1 km van de treffer liggen.
- **Niet gevonden:** de coördinaten die je stuurde kunnen niet worden geverifieerd, dus `lat` en `lon` worden weggelaten (`null`).
- **Opzoeken niet beschikbaar:** de coördinaten die je stuurde blijven zoals ze zijn.

Voor een veld met `config.service_area` meet de server daarna de afstand hemelsbreed van het startpunt tot de coördinaten van het adres en bewaart `{distance_km, in_area}` voor het veld in `meta.address` van de inzending, op veld-key (de merchant ziet het in de inbox). `distance_km` en `in_area` zijn `null` als het adres geen coördinaten heeft. Een veld zonder werkgebied voegt niets toe aan `meta.address`.

- `mode: "notice"`: de inzending wordt geaccepteerd, wat het oordeel ook is; de bezoeker heeft de melding al in jouw formulier gezien.
- `mode: "block"`: een inzending waarvan het adres aantoonbaar buiten het gebied ligt (`in_area` is `false`) geeft `422` op `answers.{key}` met de `message` van het veld in de taal van de inzending. Een adres zonder coördinaten (niet gevonden, of opzoeken niet beschikbaar en geen coördinaten van jou) wordt geaccepteerd. `block` vereist `lookup: true`, omdat alleen een opgezocht adres geverifieerde coördinaten heeft.

### Fouten

**422 validatie.** De standaard envelope (zie [Errors en rate limits](/nl/api/getting-started/errors-and-rate-limits)), met fouten per pad:

| Key | Wanneer |
| --- | --- |
| `answers` | `answers` ontbreekt of is geen object. |
| `locale` | Geen string, of langer dan 16 tekens. |
| `files`, `meta` | Aanwezig maar geen object. |
| `turnstile` | Het formulier heeft Turnstile aan en het token ontbreekt, is ongeldig, verlopen of al gebruikt, of Cloudflare kon het niet bevestigen. |
| `answers.{key}` | Het antwoord op een zichtbaar veld ontbreekt of past niet bij zijn type. |
| `files.{key}` | Een verplicht zichtbaar `file`-veld heeft geen tokens, de waarde is geen lijst tokens of heeft er meer dan `max_files`, of een token kan niet worden geclaimd. |

```json title="Response"
{
  "message": "This field is required. (and 1 more error)",
  "errors": {
    "answers.name": ["This field is required."],
    "answers.email": ["Must be an email address."]
  }
}
```

De meldingen op `answers.{key}` en `files.{key}`:

| Melding | Type |
| --- | --- |
| `This field is required.` | Elk verplicht veld dat leeg is gelaten, behalve `consent`. |
| `You must accept this.` | `consent`: verplicht en leeg gelaten (`false` telt als leeg), of een antwoord dat `true` noch leeg is. |
| `Must be text of at most 255 characters.` | `text`. |
| `Must be text of at most 10000 characters.` | `textarea`. |
| `Must be a number.` | `number`. |
| `Must be an email address.` | `email`. |
| `Must be a phone number.` | `phone`. |
| `Must be a date in YYYY-MM-DD format.` | `date`. |
| `Must be one of the configured options.` | `select`. |
| `Must be a link.`, `A link needs a url.`, `A link must be absolute, root-relative, mailto: or tel:.`, `A link label may be at most 200 characters.` | `link`. |
| `Choose one of the options.` | `choice`: een waarde die geen optie is (of geen lijst bij `multiple`). |
| `Choose at least {min}.`, `Choose at most {max}.` | `choice` met `multiple`, buiten `min` of `max`. |
| `Must be text of at most 500 characters.` | `hidden`. |
| `Must be a valid address.` | `address`: geen object, een ongeldige postcode of coördinaten buiten bereik. Een ontbrekende postcode, een ontbrekend huisnummer, een ontbrekende straat of plaats geeft `This field is required.` |
| `Must be a list of files.`, `At most {max} files are allowed.` | `file`. |
| `This upload is invalid or has expired. Upload the file again.` | `file`: een token dat niet bestaat, bij een ander formulier of veld hoort, al is gebruikt, verlopen is (24 uur na de upload) of twee keer voorkomt. |

De melding op `turnstile` is `The spam check failed. Reload the page and try again.` Reset na elke `422` de Turnstile-widget: het token is gebruikt.

`@borkol/forms` geeft precies deze meldingen terug uit `check` (behalve de upload- en Turnstile-meldingen, die alleen de server kan geven), zodat je de fouten van de server en je eigen fouten op dezelfde manier kunt tonen.

**429 limiet per uur.**

```json title="Response"
{
  "message": "Too many submissions. Try again later."
}
```

Deze response heeft geen `Retry-After`-header. Ook de winkelbrede limiet van 120 requests per minuut kan `429` geven, met de melding `Too Many Attempts.` en een `Retry-After`-header.

**Andere fouten.**

- `404` als geen formulier van de winkel deze key heeft, of als het een concept is, gearchiveerd, nooit gepubliceerd of verwijderd.
- `403` `{"error":"Module not enabled: forms"}` als de module forms uit staat voor de winkel, en `403` `{"message":"Invalid ability provided."}` als de sleutel `forms:write` mist.
- `401` zoals op elke `/v1/api`-route.
