Docs

Webhooks

Last updated: 2026-10-036 min read

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 public https:// address on the default port (443), without user:password in 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

RequestWhat it does
GET /webhooksyour subscriptions (without secrets)
PATCH /webhooks/{id}change url, events or status (active / disabled)
DELETE /webhooks/{id}delete a subscription
POST /webhooks/{id}/testsend a sample event; body {"eventType": "content.completed"} sets the sample's shape
GET /webhooks/{id}/deliverieslast 50 deliveries: status, response code, latency, start of the response
POST /webhooks/{id}/deliveries/{deliveryId}/resendsend 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

EventWhen
valuation.completed, valuation.failedfull valuation report
review.completed, review.failedreport review
content.completed, content.partial, content.failedcontent pack (partial - some files failed)
text.completed, text.failedtexts
video.completed, video.failedvideo
avatar.completed, avatar.failedavatar video
photo.completed, photo.failedphoto enhancement
floorplan.completed, floorplan.failedfloor plan
pdf.completed, pdf.failedPDF 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

EventWhen
object.createdyou created a listing
object.updateda listing changed, including by a moderator
object.publisheda listing became public
object.deleteda 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

EventWhen
lead.createda new lead addressed to you
lead.updatedthe 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

EventWhen
match.lead.offereda lead was offered to you (or an agent of your agency)
match.lead.offer_closedthe offer closed without you (time ran out)
match.lead.matchedthe lead was assigned to you
match.lead.updatedthe lead moved to a new stage
match.lead.unassignedthe 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": { ... } }
  • id is the same in every retry of one delivery - use it to avoid processing an event twice.
  • t and createdAt are the time of the specific attempt.
  • A test delivery (POST /webhooks/{id}/test) with an eventType from the list carries test: true in data; without it the event type is webhook.test. An eventType that is not in the list is 400. webhook.test itself 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 2xx within 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 with PATCH /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.

Was this article helpful?