Frontend integration
A browser posts straight to Expeed Relay. There is no SDK, no key in your bundle, and nothing to install — one fetch to one endpoint.
POST https://api.relay.expeed.com/api/v1/submissionsContent-Type: application/jsonEverything the endpoint accepts is on the API reference. This page is about wiring it into a page.
The two values you need
Section titled “The two values you need”Ask whoever administers Expeed Relay for:
siteKey— identifies the website, e.g.marketing-site-aformKey— identifies the form on it, e.g.contact-us
Both are safe in frontend code. Neither authenticates anything: what protects the endpoint is the origin allowlist and, optionally, CAPTCHA.
Plain HTML
Section titled “Plain HTML”<form id="contact-form"> <input name="name" required /> <input name="email" type="email" required /> <textarea name="message" required></textarea> <button type="submit">Send</button></form>
<script> const form = document.getElementById('contact-form');
form.addEventListener('submit', async (event) => { event.preventDefault(); const button = form.querySelector('button'); button.disabled = true;
try { const response = await fetch( 'https://api.relay.expeed.com/api/v1/submissions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteKey: 'marketing-site-a', formKey: 'contact-us', data: Object.fromEntries(new FormData(form)), }), }, );
if (!response.ok) { const problem = await response.json(); throw new Error(problem.message ?? 'Submission failed'); }
form.reset(); // Show your own success state here. } catch (error) { // Show your own error state here. console.error(error); } finally { button.disabled = false; } });</script>Object.fromEntries(new FormData(form)) turns the form into the flat object data expects. Note that it keeps only the last value for repeated field names — if you have checkbox groups, build data yourself.
import { useState } from 'react';
const ENDPOINT = 'https://api.relay.expeed.com/api/v1/submissions';
export function ContactForm() { const [status, setStatus] = useState('idle'); const [error, setError] = useState(null);
async function handleSubmit(event) { event.preventDefault(); setStatus('sending'); setError(null);
const form = event.currentTarget;
try { const response = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteKey: 'marketing-site-a', formKey: 'contact-us', data: Object.fromEntries(new FormData(form)), }), });
if (!response.ok) { const problem = await response.json(); throw new Error(problem.message ?? 'Submission failed'); }
form.reset(); setStatus('sent'); } catch (err) { setError(err.message); setStatus('idle'); } }
return ( <form onSubmit={handleSubmit}> <input name="name" required /> <input name="email" type="email" required /> <textarea name="message" required /> <button type="submit" disabled={status === 'sending'}> {status === 'sending' ? 'Sending…' : 'Send'} </button> {status === 'sent' && <p role="status">Thanks — we'll be in touch.</p>} {error && <p role="alert">{error}</p>} </form> );}Angular
Section titled “Angular”import { Component, inject, signal } from '@angular/core';import { HttpClient } from '@angular/common/http';import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
const ENDPOINT = 'https://api.relay.expeed.com/api/v1/submissions';
@Component({ selector: 'app-contact-form', standalone: true, imports: [ReactiveFormsModule], template: ` <form [formGroup]="form" (ngSubmit)="send()"> <input formControlName="name" /> <input formControlName="email" type="email" /> <textarea formControlName="message"></textarea> <button type="submit" [disabled]="sending()">Send</button> </form> @if (error()) { <p role="alert">{{ error() }}</p> } `,})export class ContactFormComponent { private readonly http = inject(HttpClient); private readonly fb = inject(FormBuilder);
readonly sending = signal(false); readonly error = signal<string | null>(null);
readonly form = this.fb.group({ name: '', email: '', message: '' });
send(): void { this.sending.set(true); this.error.set(null);
this.http .post(ENDPOINT, { siteKey: 'marketing-site-a', formKey: 'contact-us', data: this.form.getRawValue(), }) .subscribe({ next: () => { this.form.reset(); this.sending.set(false); }, error: (err) => { this.error.set(err?.error?.message ?? 'Submission failed'); this.sending.set(false); }, }); }}<script setup>import { ref } from 'vue';
const ENDPOINT = 'https://api.relay.expeed.com/api/v1/submissions';
const sending = ref(false);const error = ref(null);const model = ref({ name: '', email: '', message: '' });
async function send() { sending.value = true; error.value = null;
try { const response = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteKey: 'marketing-site-a', formKey: 'contact-us', data: { ...model.value }, }), });
if (!response.ok) { const problem = await response.json(); throw new Error(problem.message ?? 'Submission failed'); }
model.value = { name: '', email: '', message: '' }; } catch (err) { error.value = err.message; } finally { sending.value = false; }}</script>
<template> <form @submit.prevent="send"> <input v-model="model.name" /> <input v-model="model.email" type="email" /> <textarea v-model="model.message" /> <button type="submit" :disabled="sending">Send</button> </form> <p v-if="error" role="alert">{{ error }}</p></template>Content Security Policy
Section titled “Content Security Policy”If your site sends a CSP, the browser must be allowed to reach the API:
connect-src 'self' https://api.relay.expeed.com;Using CAPTCHA as well? Each provider needs its own entries — those are listed on CAPTCHA for developers.
A blocked request never reaches Expeed Relay, so nothing appears in Submissions. The browser console will name the violated directive.
What comes back
Section titled “What comes back”Success is 201 with the stored submission’s id:
{ "success": true, "submissionId": "9f3c21e4-..." }201 means Expeed Relay accepted and stored the submission — not that email has arrived. Delivery happens on a queue afterwards, with its own retries, and is visible under Submissions & jobs in the admin.
Anything else is an error with a message field. The full list, with causes, is in the API reference.
Field limits worth knowing
Section titled “Field limits worth knowing”data is free-form, but bounded. These are enforced and a breach returns 400:
| Limit | Value |
|---|---|
| Fields per object | 100 |
| Nesting depth | 2 levels (data is level 1) |
| Field name length | 128 characters |
| String value length | 10,000 characters |
| Items per array | 100 |
Rate limit
Section titled “Rate limit”This endpoint allows 10 requests per minute per IP. Over that, it returns 429. It is a per-IP limit, so a shared corporate NAT can hit it with genuine traffic — worth knowing before you conclude a form is broken.
Related
Section titled “Related”- CAPTCHA for developers — adding a widget and sending the token
- Backend integration — server-to-server with an API key
- API reference — every field and every error