Developer docs

Webhooks

When something your organization chose changes in Melora Connect, Melora Connect sends an HTTPS POST with a JSON body to your endpoint, signed so you can tell it came from Melora Connect.

Set up an endpoint

  1. In Melora Connect, open Settings and then the Webhooks tab. You need permission to manage the organization's settings.
  2. Enter the endpoint's https address, choose its events and, if you want them, tick Include member contact details. Then click Add endpoint.
  3. Copy the signing secret and store it like a password. It is shown only once.

An organization can have up to 10 endpoints, as section 3.5 of the Terms of Service says. The same tab pauses and resumes an endpoint, sends a test message, issues a new secret and shows every message with its result. A failed message can be retried from there, and the delivery log keeps 30 days.

A system can also subscribe and unsubscribe its own endpoint with an API key, through POST /webhooks and DELETE /webhooks/{id} in the REST API.

The address must use https, on port 443 or 8443, with a public host name. Addresses on a private network, loopback and link-local addresses (including cloud metadata addresses) are refused when you add the endpoint, and checked again before every message, because a host's DNS can change. Redirects are not followed.

What is sent

Each message is a POST with these headers and a JSON body:

POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Melora-Webhooks/1.0
Melora-Signature: t=1791000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Melora-Event: member.status_changed
Melora-Event-Id: 8a1f0c1e-2f7b-4a55-9b3e-0d2b6f1c9e41
Melora-Delivery: 3c9d2a70-51f4-4e0b-8f7e-6a1d2c3b4e5f
{
  "id": "8a1f0c1e-2f7b-4a55-9b3e-0d2b6f1c9e41",
  "type": "member.status_changed",
  "created_at": "2026-10-01T14:03:22.418Z",
  "organization_id": "5d0c7a4e-1b2f-4c3d-9e8f-0a1b2c3d4e5f",
  "data": {
    "member_id": "0f9e8d7c-6b5a-4938-8271-605f4e3d2c1b",
    "status": "lapsed",
    "previous_status": "active"
  }
}
  • Melora-Event is the event type, also in the body as type.
  • Melora-Event-Id is the event's id, also in the body as id. It stays the same on every retry: use it to ignore a message you have already handled.
  • Melora-Delivery identifies this message to this endpoint, and matches the delivery log on the Webhooks tab.
  • Melora-Signature carries the signature, explained below.
  • organization_id is your organization. A message only ever carries your own organization's records.

Events

Each endpoint receives the events chosen for it. Member contact details are left out unless the endpoint has Include member contact details ticked: without it, email, phone, city, state, zip, company, title, industry, website, linkedin_url, tags, show_in_directory, donor_email, guest_email are not sent, and a member.updated message whose only changes were to those fields is not sent at all. The table shows the fields with contact details on.

EventWhen it is sentFields in data
member.createdA member record is created.member: id, status, name, first_name, last_name, email, phone, company, title, industry, city, state, zip, website, linkedin_url, tier, membership_type_id, membership_category, chapter_id, corporate_membership_id, join_date, renewal_date, tags, show_in_directory, archived_at, user_id
member.updatedA member's contact or membership details change. changed holds only the fields of a member that changed, with their new values.member_id, changed
member.status_changedA member's status changes (active, lapsed, expired and the others), with the previous status.member_id, status, previous_status
member.unsubscribedA member opts out of your marketing email: an unsubscribe link, a complaint, their email switch, Mailchimp or the API. Sent once, only when the opt-out changes; email_marketing_opted_out_at is when.member_id, email_marketing_opt_out, email_marketing_opted_out_at, email
member.resubscribedA member who had opted out can receive your marketing email again, for example after turning it back on themselves. Sent once, only when the opt-out changes; email_marketing_opted_out_at is null.member_id, email_marketing_opt_out, email_marketing_opted_out_at, email
event.registration.createdSomeone registers for one of your events.registration: id, event_id, user_id, status, tier_id, guest_name, guest_email, group_order_id, created_at
dues_invoice.paidA dues invoice is paid, online or recorded by staff. previous_status is the invoice's status before.invoice: id, invoice_number, member_id, user_id, dues_subscription_id, amount_cents, currency, paid_at, paid_via, period_start, period_end, due_at; previous_status
donation.completedA donation is recorded or a card donation completes. Its status is received or completed. For an anonymous donor, donor_name, donor_email and user_id are left out.donation: id, amount_cents, currency, status, fundraiser_id, campaign, designation, received_at, source, anonymous, donor_name, donor_email, user_id

webhook.test is sent only by the Send test button, with data.message.

Check the signature

The Melora-Signature header looks like this:

Melora-Signature: t=1791000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  1. Take t (Unix seconds) and every v1 value from the header.
  2. Refuse the message if t is more than 300 seconds from your clock. The timestamp is part of what is signed, so this stops an old message from being replayed.
  3. Compute HMAC-SHA-256, keyed with your whole signing secret (including the whsec_ prefix), over t, a full stop, and the raw request body exactly as received. Hex-encode it.
  4. Accept the message if any v1 value equals your result. Compare in constant time.

Check the raw body before parsing it: serializing the JSON again changes the bytes, and the signature will not match.

Node.js

import crypto from "node:crypto";

// header:  the Melora-Signature request header
// rawBody: the request body exactly as received, as a string (before JSON.parse)
// secret:  the endpoint's signing secret (it starts with whsec_)
export function verifyMeloraSignature(header, rawBody, secret, toleranceSeconds = 300) {
  const parts = String(header || "").split(",").map((p) => p.trim());
  const t = parts.find((p) => p.startsWith("t="))?.slice(2);
  const signatures = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
  if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest("hex");
  return signatures.some(
    (s) =>
      s.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
  );
}

Python

import hashlib
import hmac
import time


def verify_melora_signature(header, raw_body, secret, tolerance_seconds=300):
    """header: the Melora-Signature header. raw_body: the request body as bytes,
    exactly as received. secret: the endpoint's signing secret (it starts with whsec_)."""
    parts = [p.strip() for p in (header or "").split(",")]
    t = next((p[2:] for p in parts if p.startswith("t=")), None)
    signatures = [p[3:] for p in parts if p.startswith("v1=")]
    if not t or not t.isdigit() or not signatures:
        return False
    if abs(time.time() - int(t)) > tolerance_seconds:
        return False
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s, expected) for s in signatures)

Answers, retries and order

Answer with any 2xx status within 8 seconds. Anything else, a timeout or a redirect counts as a failed attempt, and the message is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours: 6 attempts in all. After that it is marked failed in the delivery log, where staff can retry it.

If 5 messages in a row fail every attempt, Melora Connect pauses the endpoint, says so on the Webhooks tab, and tells the organization's settings staff once, in the app and by email. While an endpoint is paused, no new messages are queued for it; messages already waiting are sent when it is resumed.

Messages are not guaranteed to arrive in the order the changes happened, and a message can arrive more than once. Use created_at and the event id, and read the current record through the REST API when order matters.

Rotate the secret

Issuing a new secret on the Webhooks tab shows the new signing secret once. For the next 24 hours, every message carries two v1 values, one for the new secret and one for the old, so you can switch without missing a message. After that, only the new secret signs.

See it with your own data

Ready to give your members the platform they deserve?

A walkthrough with our team, built around how your organization works today. Live from 5 weeks after you sign, with a Melora specialist doing the setup.

Book a demoEstimate your go-live date