Class: Nombaone::Webhooks

Inherits:
Object
  • Object
show all
Defined in:
lib/nombaone/webhooks.rb,
sig/nombaone/webhooks.rbs

Overview

Verify and parse incoming NombaOne webhook deliveries.

Available as nombaone.webhooks on a client, or standalone via webhooks — verification needs only the endpoint's signing secret, never an API key, so it works in a receiver that never builds a client.

Feed it the raw request body. Parsing and re-serializing JSON can reorder keys and change bytes, which breaks the signature. Capture the body before any middleware parses it (request.raw_post in Rack/Rails).

Examples:

Rails controller

def receive
  event = Nombaone.webhooks.construct_event(
    request.raw_post,
    request.headers["X-Nombaone-Signature"],
    ENV.fetch("NOMBAONE_WEBHOOK_SECRET"),
  )
  return head(:ok) if already_processed?(event.event.id) # at-least-once ⇒ dedupe

  unlock(event.data.reference) if event.type == Nombaone::WebhookEventType::INVOICE_PAID
  head :ok # respond 2xx fast; do heavy work async
rescue Nombaone::WebhookVerificationError
  head :bad_request
end

Constant Summary collapse

DEFAULT_TOLERANCE_SECONDS =

Maximum allowed age (seconds) between the delivery's t timestamp and now.

Returns:

  • (Integer)
300

Instance Method Summary collapse

Instance Method Details

#construct_event(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS) ⇒ WebhookEvent

Verify a delivery's signature and timestamp, then parse and return the typed event. This is the one call your handler needs.

Parameters:

  • payload (String)

    the exact raw request body.

  • signature_header (String)

    the X-Nombaone-Signature header value.

  • secret (String)

    the endpoint's signing secret (shown once at creation).

  • tolerance (Numeric) (defaults to: DEFAULT_TOLERANCE_SECONDS)

    max timestamp age in seconds (default 300).

Returns:

Raises:

  • (WebhookVerificationError)

    on a missing/malformed header, a stale timestamp, an invalid signature, or a non-JSON body.



45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/nombaone/webhooks.rb', line 45

def construct_event(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS)
  verify_signature(payload, signature_header, secret, tolerance: tolerance)

  begin
    parsed = JSON.parse(payload.to_s)
  rescue JSON::ParserError
    raise WebhookVerificationError, "Webhook payload was not valid JSON."
  end
  unless parsed.is_a?(Hash)
    raise WebhookVerificationError, "Webhook payload was not a JSON object."
  end

  WebhookEvent.new(ensure_event_block(parsed))
end

#generate_test_header(payload:, secret:, timestamp: nil) ⇒ String

Build a valid X-Nombaone-Signature header for a payload — for testing your own handler without waiting on a real delivery.

Examples:

header = Nombaone.webhooks.generate_test_header(payload: body, secret: secret)
event = Nombaone.webhooks.construct_event(body, header, secret)

Parameters:

  • payload (String)

    the raw body you will pass to #construct_event.

  • secret (String)

    the signing secret.

  • timestamp (Integer, nil) (defaults to: nil)

    unix seconds (defaults to now).

  • payload: (String)
  • secret: (String)
  • timestamp: (Integer, nil) (defaults to: nil)

Returns:

  • (String)

    a t=<unix>,v1=<hex> header value.



101
102
103
104
# File 'lib/nombaone/webhooks.rb', line 101

def generate_test_header(payload:, secret:, timestamp: nil)
  timestamp = (timestamp || Time.now.to_i).to_s
  "t=#{timestamp},v1=#{compute_signature(secret, timestamp, payload.to_s)}"
end

#verify_signature(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS) ⇒ true

Verify a delivery's signature and timestamp only (no parse). Returns true on success; raises with a distinct message per failure mode.

Parameters:

  • payload (String)

    the exact raw request body.

  • signature_header (String)

    the X-Nombaone-Signature header value.

  • secret (String)

    the endpoint's signing secret.

  • tolerance (Numeric) (defaults to: DEFAULT_TOLERANCE_SECONDS)

    max timestamp age in seconds (default 300).

Returns:

  • (true)

Raises:



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/nombaone/webhooks.rb', line 69

def verify_signature(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS)
  if signature_header.nil? || signature_header.empty?
    raise WebhookVerificationError,
          "Missing X-Nombaone-Signature header — is this request really from NombaOne?"
  end
  if secret.nil? || secret.empty?
    raise WebhookVerificationError,
          "Missing signing secret — pass the secret shown when the endpoint was created."
  end

  timestamp, signatures = parse_signature_header(signature_header)
  assert_within_tolerance(timestamp, tolerance)

  expected = compute_signature(secret, timestamp, payload.to_s)
  return true if signatures.any? { |candidate| secure_compare(candidate, expected) }

  raise WebhookVerificationError,
        "Webhook signature verification failed — check you are using this endpoint's " \
        "current signing secret and the exact raw request body (no re-serialization)."
end