Webhook Developer Guide

Greenstamp webhooks let you receive real-time, signed event notifications the moment things happen in your account — invoice stamped, bill paid, certificate expiring — without polling the API.

Overview

Webhooks are push-based: you register an HTTPS endpoint, and Greenstamp POSTs a signed JSON payload to it whenever a subscribed event occurs. This is the inverse of the REST API, where you pull data on demand.

If you need to query historical events or fill a gap after downtime, you can reconcile against the pull-based Events API — use X-Webhook-ID as a correlation key.

Getting started

  1. Register an endpoint — go to Settings → Webhooks in the app, or use the webhook_endpoints REST resource. Provide a public HTTPS URL and select which event types to subscribe to.
  2. Save your secret — after creation, you are shown a signing secret once. Copy it immediately; Greenstamp stores only a derived hash and cannot reveal the original again. You can rotate it later if needed.
  3. Deploy your receiver — a minimal receiver needs to verify the X-Webhook-Signature header (see Verifying signatures) and return 200 OK.

Endpoint management CRUD (create, list, update, delete, rotate secret) is documented in the REST API Reference under webhook_endpoints. This guide covers the consumer side — what you receive and how to process it.

The request we send

Headers

Every delivery is an HTTP POST with Content-Type: application/json. The following headers are always present:

Header Example value Purpose
X-Webhook-ID wdel_AbCdEfGh1234567890abcd Unique delivery ID. Use this to deduplicate retries.
X-Webhook-Timestamp 1718200000 Unix timestamp (seconds) of when the delivery was sent. Use to reject stale replays.
X-Webhook-Signature a3f9b2... (64 hex chars) HMAC-SHA256 signature. See Verifying signatures.
User-Agent Greenstamp-Webhook/1.0 Identifies the sender.
Content-Type application/json Body is always JSON.

Payload shape

Every event envelope has the same top-level shape. The data field contains event-specific attributes.

{
  "id":          "evt_AbCdEfGh1234567890abcdef",   // unique event ID (same as X-Webhook-ID event part)
  "type":        "invoice.stamped",                  // event type — see Event catalog
  "created_at":  "2024-06-07T10:30:00Z",             // ISO 8601 UTC
  "api_version": "v1",
  "entity": {
    "id":         "entt_...",                        // the entity this event concerns
    "legal_name": "Acme SA de CV"
  },
  "data": { ... }                                    // event-specific payload (see below)
}

Example — invoice.stamped

{
  "id":          "evt_AbCdEfGh1234567890abcdef",
  "type":        "invoice.stamped",
  "created_at":  "2024-06-07T10:30:00Z",
  "api_version": "v1",
  "entity": {
    "id":         "entt_Xy9mNpQ3rsTu",
    "legal_name": "Acme SA de CV"
  },
  "data": {
    "id":       "inv_Lm4kP8vWzXqR",
    "folio":    "A-0042",
    "status":   "stamped",
    "total":    116000,
    "currency": "MXN",
    "counterparty": {
      "id":   "cpty_Rn7tBxYcVdEs",
      "name": "Globex Corp"
    }
  }
}

Verifying signatures

Always verify signatures. Without verification, your endpoint accepts any POST as a legitimate Greenstamp event — an attacker could forge deliveries and trigger business logic in your system.

The exact recipe

Greenstamp signs each delivery as follows. You must reproduce this exactly — a single-character difference produces a completely different signature.

  1. Derive the signing key — the server does not sign with your raw secret directly. It signs with SHA256(raw_secret). The raw secret is the 64-character hex string you copied at creation time.

    key = SHA256_hex(raw_secret)      # 64-char hex → 64-char hex
  2. Build the signed string — concatenate the X-Webhook-Timestamp value, a literal dot, and the raw request body bytes (do not re-serialize the JSON):

    message = X-Webhook-Timestamp + "." + request_body
  3. Compute the HMAC:

    expected = HMAC-SHA256(key, message) # as lowercase hex
  4. Compare with constant-time equality — use your language's timing-safe compare to avoid timing attacks.

Why SHA256(raw_secret) and not the raw secret? Greenstamp never stores your raw secret — only secret_digest = SHA256(raw_secret). The server signs using secret_digest as the HMAC key. Your receiver must perform the same derivation step before computing the HMAC.

Code examples

require 'openssl'
require 'digest'

# raw_secret   — the 64-char hex string shown at webhook creation
# timestamp    — X-Webhook-Timestamp header value (string)
# body         — raw request body (string, NOT re-parsed JSON)
# sig_header   — X-Webhook-Signature header value
def verify_webhook(raw_secret, timestamp, body, sig_header)
  # Step 1: derive the signing key the same way the server does
  key = Digest::SHA256.hexdigest(raw_secret)

  # Step 2: build the signed string
  message = "#{timestamp}.#{body}"

  # Step 3: compute HMAC-SHA256
  expected = OpenSSL::HMAC.hexdigest('SHA256', key, message)

  # Step 4: constant-time compare (Rails / ActiveSupport)
  ActiveSupport::SecurityUtils.secure_compare(sig_header, expected)
end

# In a Rails controller:
def create
  timestamp  = request.headers['X-Webhook-Timestamp']
  signature  = request.headers['X-Webhook-Signature']
  body       = request.raw_post

  # Reject if timestamp is more than 5 minutes old
  if (Time.now.to_i - timestamp.to_i).abs > 300
    head :forbidden and return
  end

  unless verify_webhook(ENV['GREENSTAMP_WEBHOOK_SECRET'], timestamp, body, signature)
    head :forbidden and return
  end

  event = JSON.parse(body)
  # process event...
  head :ok
end
const crypto = require('crypto');

// rawSecret   — the 64-char hex string shown at webhook creation
// timestamp   — X-Webhook-Timestamp header value (string)
// body        — raw request body (string, NOT re-parsed JSON)
// sigHeader   — X-Webhook-Signature header value
function verifyWebhook(rawSecret, timestamp, body, sigHeader) {
  // Step 1: derive the signing key the same way the server does
  const key = crypto.createHash('sha256').update(rawSecret).digest('hex');

  // Step 2: build the signed string
  const message = `${timestamp}.${body}`;

  // Step 3: compute HMAC-SHA256
  const expected = crypto.createHmac('sha256', key).update(message).digest('hex');

  // Step 4: constant-time compare
  try {
    return crypto.timingSafeEqual(
      Buffer.from(sigHeader, 'hex'),
      Buffer.from(expected, 'hex')
    );
  } catch {
    return false; // mismatched lengths or bad hex
  }
}

// Express middleware example:
app.post('/webhooks/greenstamp', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.headers['x-webhook-timestamp'];
  const signature = req.headers['x-webhook-signature'];
  const body      = req.body.toString(); // raw bytes → string

  // Reject if timestamp is more than 5 minutes old
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.sendStatus(403);
  }

  if (!verifyWebhook(process.env.GREENSTAMP_WEBHOOK_SECRET, timestamp, body, signature)) {
    return res.sendStatus(403);
  }

  const event = JSON.parse(body);
  // process event...
  res.sendStatus(200);
});

Replay protection

The X-Webhook-Timestamp header lets you detect and reject replayed requests. The examples above reject any delivery older than 5 minutes — adjust the window to fit your SLA, but keep it short enough to prevent replay attacks.

For an additional layer of replay protection, store processed X-Webhook-ID values and reject duplicates (see Best practices).

Event catalog

Greenstamp emits 28 event types across 6 domains. The list below is generated from the model constants — it updates automatically when new event types are added.

Invoices

Event type Description
invoice.created Invoice record created; not yet submitted for stamping.
invoice.submitted Stamp request submitted to the tax authority.
invoice.stamped Successfully stamped by the tax authority; UUID assigned.
invoice.stamp_failed Stamp request rejected by the tax authority.
invoice.filing_failed Filing step failed after stamping.
invoice.cancellation_requested Cancellation request submitted to the tax authority.
invoice.canceled Invoice successfully canceled.
invoice.payment_received A payment was applied to the invoice.
invoice.paid Invoice marked as fully paid.
invoice.issued Invoice issued to the counterparty.

Bills

Event type Description
bill.created AP bill record created.
bill.received Bill received from supplier.
bill.validated Bill passed validation checks.
bill.validation_failed Bill failed one or more validation checks.
bill.rejected Bill rejected.
bill.paid Bill marked as fully paid.

Credit Notes

Event type Description
invoice_credit_note.created Credit note created against an invoice.
invoice_credit_note.stamped Credit note successfully stamped by the tax authority.
invoice_credit_note.stamp_failed Credit note stamp request rejected.
invoice_credit_note.canceled Credit note canceled.

Payments

Event type Description
payment.recorded Payment recorded.
payment.complement_stamped Mexico CFDI payment complement successfully stamped.
payment.complement_failed Payment complement stamping failed.

Entities

Event type Description
entity.certificate_expiring Entity's e-signing certificate is expiring within 30 days.
entity.certificate_expired Entity's e-signing certificate has expired.
entity.certificate_invalid Entity's e-signing certificate is invalid.

Counterparties

Event type Description
counterparty.created New counterparty created.
supplier.created New supplier created.

Retries & failure handling

Greenstamp considers any 2xx response a success. Any other response — including timeouts — is treated as a failure and triggers the retry schedule.

Retry schedule

Each delivery gets up to 5 attempts total (1 initial + 4 retries). If all 5 fail, the delivery is marked failed permanently.

Attempt Delay after previous failure
1st (initial)Immediate
2nd1 minute
3rd5 minutes
4th30 minutes
5th (final)2 hours

Auto-disable

If a destination accumulates 10 consecutive failures with no successful delivery in between, it is automatically disabled with reason "Automatically disabled after 10 consecutive failures". You will need to re-enable it manually from the Webhooks UI or via the API after the issue is resolved.

Request timeout

Greenstamp waits up to 30 seconds for a response (10-second connection timeout). If your handler needs more time, acknowledge immediately with 200 OK and do the work asynchronously — see Best practices.

Delivery log

Every attempt is logged in the Webhooks UI: status, response code, response body, and latency. Use this to debug failed deliveries and inspect exact payloads.

Building a reliable receiver


Related: REST API Reference · MCP / Agent Tools