Skip to content

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/submissions
Content-Type: application/json

Everything the endpoint accepts is on the API reference. This page is about wiring it into a page.

Ask whoever administers Expeed Relay for:

  • siteKey — identifies the website, e.g. marketing-site-a
  • formKey — 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.

<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>
);
}
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>

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.

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.

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

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.