Vepino Subscribe API
Add subscribers to a distribution list from your own forms, CRM, or backend.
This API is designed for partner integrations — sign-up forms on your website, lead-capture from a CRM, customer imports from external systems. Each API key is bound to one distribution list, so the calling system never has to specify which list to write to.
Base URLs
https://api.vepino.com
https://api.demo.vepino.com
https://api.stage.vepino.com
Click a card to copy the URL.
Quick start
A single POST is all you need:
curl -X POST https://api.vepino.com/v1/subscribe \
-H "Content-Type: application/json" \
-H "X-API-Key: m2_your_key_here" \
-d '{
"email": "subscriber@example.com",
"first_name": "Alex",
"legal_basis": "consent"
}'
Expected response:
{
"success": true,
"data": {
"email": "subscriber@example.com",
"list": "newsletter-q1",
"created": true
}
}
Getting an API key
API keys are created by a Vepino workspace admin and tied to a specific distribution list. Ask your Vepino contact to:
- Open the distribution list you want to integrate against
- Generate a new API key under "API access" and give it a descriptive name (e.g. "Partner XYZ — landing-page form")
- Optionally restrict the key to specific origins (e.g.
https://yoursite.com) - Optionally enable HMAC request signing for higher security
- Copy the plaintext key immediately — it's shown only once
The key has the format m2_ followed by 40 random characters (43 total). Store it like any other secret — environment variable or secrets manager. Never commit it to a repo.
Endpoint
POST /v1/subscribe
Headers
| Header | Required | Value |
|---|---|---|
Content-Type | Yes | application/json |
Accept | Recommended | application/json |
X-API-Key | Yes | Your m2_… key |
X-Signature | Only if HMAC enabled | hmac-sha256={base64-encoded-hash} |
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | Yes | Valid email, max 255 characters |
legal_basis | string | Yes | GDPR legal basis — see below |
first_name | string | No | Max 255 characters |
last_name | string | No | Max 255 characters |
company | string | No | Max 255 characters |
mobile | string | No | Should be in E.164 format if provided (see below). Invalid formats are silently dropped with a warning — the subscription still succeeds. Empty string or omitted = "not given." |
legal_basis
You must declare the GDPR legal basis for adding this person to the list. The API does not assume consent on your behalf.
| Value | When to use |
|---|---|
consent | The person actively opted in (checkbox, sign-up form, etc.) |
contract | Subscription is part of fulfilling a contract (e.g. service customers receiving operational mail) |
legitimate_interest | You're relying on legitimate-interest grounds for direct marketing |
If you can't honestly justify the value you send, you shouldn't be sending the request.
Mobile in E.164
If you send a mobile number, it should be in international E.164 format:
- Starts with
+, followed by a country code (1–9), then 7–14 digits - Examples:
+46701234567,+447700900000,+4791234567
Local formats like 0701234567 or 070-123 45 67 are not rejected — but they're also not stored. The mobile field on the recipient is set to null, and the response includes a warning:
{
"success": true,
"data": {
"email": "subscriber@example.com",
"list": "newsletter-q1",
"created": true,
"warnings": ["mobile_invalid_format"]
}
}
This means the subscription still goes through — a bad mobile number doesn't disqualify the contact. The warning exists so you can spot a broken form on your side: if you see mobile_invalid_format showing up repeatedly, your client-side normalization to E.164 needs fixing.
Convert local formats to E.164 on your side before calling the API to avoid the warning entirely.
Responses
All responses — success and error — use the same envelope shape.
Success
{
"success": true,
"data": {
"email": "subscriber@example.com",
"list": "newsletter-q1",
"created": true
}
}
data.created: true— a new list membership was createddata.created: false— already on the list; the call is idempotent, no duplicate createddata.warnings— optional array of soft-failure codes (e.g.["mobile_invalid_format"]). Present only when something in the payload was ignored. The subscription still succeeded; the warning tells you what was dropped so you can fix your form. Absent when there are no warnings.
Error
{
"success": false,
"error": {
"code": "validation_failed",
"message": "Request validation failed.",
"details": {
"email": ["The email field is required."],
"legal_basis": ["The legal basis field is required."]
}
}
}
error.code— machine-readable identifier. Branch on this in your integration.error.message— human-readable text for logging or fallback UI.error.details— present only onvalidation_failed; maps fields to error messages.
Error codes
| HTTP | error.code | Meaning |
|---|---|---|
| 422 | validation_failed | Request body didn't match the schema. details has field-level messages. |
| 422 | email_invalid | Email passed format check but failed deliverability verification. |
| 401 | unauthorized | Missing/invalid X-API-Key, or HMAC signature missing/wrong. |
| 403 | forbidden_list_inactive | The distribution list tied to this key has been deactivated. |
| 403 | forbidden_origin | The request's Origin isn't in the key's allow-list. |
| 429 | rate_limited | Rate limit exceeded. Back off and retry. |
| 404 | not_found | The path doesn't exist on the API host. |
Treat 5xx responses as transient — retry with exponential backoff.
Warning codes (data.warnings[])
These are returned on successful (200) responses when part of the payload was ignored but the subscription still went through.
| Warning code | Meaning |
|---|---|
mobile_invalid_format | The mobile field wasn't in E.164 format. The value was dropped (mobile is null on the recipient); your form's normalization to E.164 is the most likely cause. |
Behavior
Idempotency
POST /v1/subscribe is idempotent on (workspace, list, email):
- Email not on the list → membership created,
data.created: true - Already on the list → no change,
data.created: false, still HTTP 200
Safe to retry on network failure.
Existing recipients
If the email already exists in the workspace, only empty fields are updated from your payload. Existing values are never overwritten. This protects data collected via richer channels (e.g. a full registration form). The merge happens at the recipient level — list membership doesn't change the behavior.
Email verification
Email addresses are verified synchronously before the response returns. Addresses that fail verification result in email_invalid. Other signals (catch-all, disposable, unknown) pass through — Vepino tracks the result internally but doesn't reject the subscription.
Triggered side effects
A successful new subscription may automatically:
- Send invitation emails for events the list is connected to
- Start a "newcomer" drip campaign if the list has one configured
These happen inside the request transaction — if the call returns success, side effects are queued.
Audit trail
Every attempt is logged with the API key id, IP address, declared legal_basis, and timestamp — providing GDPR-grade auditability.
Rate limits
| Limit | Threshold |
|---|---|
| Per source IP | 30 requests / minute |
| Per API key | 60 requests / minute |
Exceeding either returns 429 rate_limited. Implement exponential backoff. For bulk imports, batch into hourly windows or contact Vepino about a higher-limit key.
These limits reflect current defaults and may be adjusted over time. Your integration should always handle 429 gracefully regardless of the exact thresholds.
Code examples
This is a server-to-server API. Never expose your API key in client-side code (browser JavaScript, mobile apps, etc.). All calls should be made from your backend.
async function subscribe(email, fields = {}) {
const res = await fetch('https://api.vepino.com/v1/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-API-Key': process.env.VEPINO_API_KEY,
},
body: JSON.stringify({ email, legal_basis: 'consent', ...fields }),
});
const body = await res.json();
if (body.success) {
return { ok: true, alreadySubscribed: !body.data.created };
}
switch (body.error.code) {
case 'email_invalid':
return { ok: false, userMessage: 'That email address doesn\'t seem valid.' };
case 'validation_failed':
return { ok: false, fieldErrors: body.error.details };
case 'rate_limited':
return { ok: false, retry: true };
default:
console.error('Vepino subscribe error:', body.error);
return { ok: false, userMessage: 'Something went wrong. Please try again.' };
}
}
$response = file_get_contents('https://api.vepino.com/v1/subscribe', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => [
'Content-Type: application/json',
'Accept: application/json',
'X-API-Key: ' . getenv('VEPINO_API_KEY'),
],
'content' => json_encode([
'email' => 'subscriber@example.com',
'first_name' => 'Alex',
'mobile' => '+46701234567',
'legal_basis' => 'consent',
]),
'ignore_errors' => true,
],
]));
$body = json_decode($response, true);
if ($body['success']) {
$created = $body['data']['created'];
// true = new, false = already subscribed
} else {
match ($body['error']['code']) {
'email_invalid' => /* show user error */,
'validation_failed' => /* show field-level errors */,
'rate_limited' => /* back off and retry */,
default => /* log + alert ops */,
};
}
import os
import requests
def subscribe(email, **fields):
body = {'email': email, 'legal_basis': 'consent', **fields}
response = requests.post(
'https://api.vepino.com/v1/subscribe',
json=body,
headers={
'X-API-Key': os.environ['VEPINO_API_KEY'],
'Accept': 'application/json',
},
timeout=10,
)
payload = response.json()
if payload['success']:
return payload['data']
code = payload['error']['code']
if code == 'email_invalid':
raise ValueError("Email address is not deliverable")
if code == 'validation_failed':
raise ValueError(f"Validation failed: {payload['error']['details']}")
raise RuntimeError(f"Vepino subscribe failed: {code}")
HMAC request signing
For high-security integrations, an API key can be issued with HMAC signing enabled.
signature = base64( HMAC-SHA256(request_body, api_secret) )
header = "hmac-sha256=" + signature
The api_secret is shown alongside the API key at creation time and is never retrievable afterward. Requests without a valid signature on a signed key return 401 unauthorized.
You only need this if your Vepino contact has explicitly configured your key with signing — most integrations don't.
CORS
The API accepts cross-origin requests on /v1/*. If your key has an allow-list of origins, the request's Origin header must match (otherwise 403 forbidden_origin). If the allow-list is empty, all origins are accepted.
Permitted request headers: X-API-Key, Content-Type, Accept.
Versioning
The API is versioned in the URL (/v1/). Breaking changes ship under a new version (/v2/) — /v1/ is the long-lived stable contract.
Non-breaking additions (new optional fields, new error.code values, new endpoints) can land within /v1/ without notice. Your integration should:
- Tolerate unknown fields in the response (don't crash if
data.something_newshows up) - Treat unknown
error.codevalues as generic failures (fall through to a default handler)
Support
For API key issuance, rate-limit increases, or integration questions, contact your Vepino account manager or the workspace admin who issued your key. You can also reach us at hello@vepino.com.