A webhook is a POST GREM sends to your URL when something happens: a job finishes, a listing or a lead changes. Managing subscriptions needs the webhooks scope and is not charged. Webhooks of an agency are managed with the agency-wide key; an agent-scoped key gets 403.
Subscribe
curl -X POST https://developers.grem.capital/api/v1/webhooks \
-H "Authorization: Bearer gsk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/grem-hook","events":["content.completed","content.failed","lead.created"]}'
{ "id": "...", "url": "https://example.com/grem-hook", "events": [...], "status": "active",
"failureCount": 0, "lastDeliveryAt": null, "secret": "whsec_..." }
url- a publichttps://address on the default port (443), withoutuser:passwordin it. Other ports, credentials in the address, private, local and internal addresses are refused (400). The port rule applies when you set an address: endpoints saved earlier on another port keep receiving deliveries.events- at least one event from the list below; unknown names are dropped.secret- the signing secret. It is returned only once - store it.- Up to 10 addresses per account, and separately up to 10 sandbox addresses made with test keys; the 11th is refused with
422 business_rule. - Endpoints created with a test key are sandbox endpoints: they get only sandbox job events (Sandbox).
Subscriptions can also be managed in the cabinet: API → Webhooks. The signing secret can be changed there.
Management
| Request | What it does |
|---|---|
GET /webhooks | your subscriptions (without secrets) |
PATCH /webhooks/{id} | change url, events or status (active / disabled) |
DELETE /webhooks/{id} | delete a subscription |
POST /webhooks/{id}/test | send a sample event; body {"eventType": "content.completed"} sets the sample's shape |
GET /webhooks/{id}/deliveries | last 50 deliveries: status, response code, latency, start of the response |
POST /webhooks/{id}/deliveries/{deliveryId}/resend | send a delivery again; deliveryId is the _id of a row from the log |
PATCH with no valid event in events or with a status other than active / disabled is refused with 400. DELETE of an unknown subscription answers 404. In the delivery log, payload is the event's data.
Events
Finished jobs
| Event | When |
|---|---|
valuation.completed, valuation.failed | full valuation report |
review.completed, review.failed | report review |
content.completed, content.partial, content.failed | content pack (partial - some files failed) |
text.completed, text.failed | texts |
video.completed, video.failed | video |
avatar.completed, avatar.failed | avatar video |
photo.completed, photo.failed | photo enhancement |
floorplan.completed, floorplan.failed | floor plan |
pdf.completed, pdf.failed | PDF presentation |
data: { "jobId", "status", "state" }. status is the tool's raw status in upper case (for example COMPLETED) - branch on state. If a job has not finished within 30 minutes - .failed with "reason": "timeout". A job is tracked only if a subscription to its event existed when the job was created (Jobs and results).
Listings
| Event | When |
|---|---|
object.created | you created a listing |
object.updated | a listing changed, including by a moderator |
object.published | a listing became public |
object.deleted | a draft was deleted or a published listing archived |
data: { "objectId", "kind", "status", "occurredAt" }; object.deleted has mode (deleted or archived) instead of status. Full data - GET /objects/{kind}/{id}.
Leads
| Event | When |
|---|---|
lead.created | a new lead addressed to you |
lead.updated | the status or the assignee changed |
data: { "leadId", "status", "type", "source", "objectId", "occurredAt" }. No client personal data in the event - read the full lead with GET /leads/{id}.
Swipe Match
| Event | When |
|---|---|
match.lead.offered | a lead was offered to you (or an agent of your agency) |
match.lead.offer_closed | the offer closed without you (time ran out) |
match.lead.matched | the lead was assigned to you |
match.lead.updated | the lead moved to a new stage |
match.lead.unassigned | the lead was taken off you |
data: { "leadId", "eventId", "eventCode", "occurredAt", "source", "stage", "active", "leadKind", "clientType", "country", "brokerId", "agencyId" }. The client's name, phone and e-mail are never in events (Swipe Match).
Delivery format
POST https://example.com/grem-hook
Content-Type: application/json
x-grem-event: content.completed
x-grem-signature: t=1759400000,v1=3b78a03b78058d229023db2a0b4ee072...
{ "id": "...", "type": "content.completed", "createdAt": "2026-10-02T10:00:00.000Z", "data": { ... } }
idis the same in every retry of one delivery - use it to avoid processing an event twice.tandcreatedAtare the time of the specific attempt.- A test delivery (
POST /webhooks/{id}/test) with aneventTypefrom the list carriestest: trueindata; without it the event type iswebhook.test. AneventTypethat is not in the list is400.webhook.testitself is accepted.
Verifying the signature
The signature is an HMAC-SHA256 of the string "<t>.<raw body>" keyed with the subscription secret, hex-encoded in v1. Verify before any processing and reject stale t values (replay protection).
Compute the signature over the raw request body, exactly as received, before parsing JSON. Re-serializing changes the bytes and the signature will not match.
const crypto = require('crypto');
function verifyGremSignature(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.fromEntries(String(header || '').split(',').map((kv) => kv.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/grem-hook', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyGremSignature(process.env.GREM_WEBHOOK_SECRET, req.get('x-grem-signature'), raw)) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(raw);
// event.id, event.type, event.data
res.sendStatus(200);
});
In the SDK, verifyWebhookSignature() does the same.
Retries and disabling
- A delivery succeeds when your server answers
2xxwithin 10 seconds. Answer fast and do heavy work in the background. - A failed delivery is retried after 5 minutes, 30 minutes, 2 hours and 6 hours - up to 5 attempts in total.
- After 5 deliveries in a row fail completely, the subscription is disabled (
status: disabled). Re-enable it withPATCH /webhooks/{id}and{"status":"active"}, or in the cabinet. - The delivery log (
GET /webhooks/{id}/deliveries) shows your server's response code and the start of its body - that is where you see why a delivery failed.
Related
- Jobs and results - job states.
- Swipe Match - module lead event codes.