Module: Nfe::Webhook

Defined in:
lib/nfe/webhook.rb,
sig/nfe/webhook.rbs

Overview

Stateless helpers for verifying NFE.io webhook deliveries. This is the canonical signature-verification API: it needs no Client, reads no Configuration, and performs no network access — the caller supplies the raw payload bytes, the X-Hub-Signature header value, and the secret.

IMPORTANT: pass the RAW request body bytes

NFE.io signs the exact bytes it delivered. Read the raw body BEFORE parsing JSON (e.g. request.body.read in Rack/Rails) and pass those bytes. Do NOT re-serialize a parsed object (+payload.to_json+) — key order and whitespace will differ from the signed bytes and verification will fail unpredictably.

raw = request.body.read
sig = request.get_header("HTTP_X_HUB_SIGNATURE")
if Nfe::Webhook.verify_signature(payload: raw, signature: sig, secret: ENV["NFE_WEBHOOK_SECRET"])
event = Nfe::Webhook.construct_event(payload: raw, signature: sig, secret: ENV["NFE_WEBHOOK_SECRET"])
# event.id => dedupe on this; NFE.io sends no timestamp/nonce, so a valid
# signature proves authenticity but NOT freshness. Handlers MUST be
# idempotent and dedupe on the event/invoice id.
end

Only the X-Hub-Signature + HMAC-SHA1 scheme is supported. The legacy X-NFe-Signature / HMAC-SHA256 scheme is intentionally NOT implemented; a sha256= header is rejected.

Constant Summary collapse

SIGNATURE_PREFIX =

Wire prefix on the X-Hub-Signature header value.

Returns:

  • (String)
"sha1="
HEX_RE =

HMAC-SHA1 hex digests are always 40 lowercase hex characters.

Returns:

  • (Regexp)
/\A[a-f0-9]{40}\z/

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.build_event(decoded) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Unwrap the +action+/+payload+ or +event+/+data+ envelope into a Nfe::WebhookEvent; falls back to a flat +type+/+event_type+/+action+ shape carrying the whole body as data.



105
106
107
108
109
110
111
112
113
114
115
# File 'lib/nfe/webhook.rb', line 105

def build_event(decoded)
  type = first_string(decoded, "action", "event", "type", "event_type")
  data = envelope_data(decoded)

  Nfe::WebhookEvent.new(
    type: type,
    data: data,
    id: nullable_string(decoded["id"] || data["id"]),
    created_at: nullable_string(decoded["createdAt"] || data["createdAt"])
  )
end

.construct_event(payload:, signature:, secret:) ⇒ Nfe::WebhookEvent

Verify, then parse and unwrap a delivery into a Nfe::WebhookEvent.

Parameters:

  • payload (String)

    the raw, byte-exact request body.

  • signature (String, Array<String>, nil)

    the X-Hub-Signature value.

  • secret (String, nil)

    the webhook secret.

Returns:

Raises:



79
80
81
82
83
84
85
86
# File 'lib/nfe/webhook.rb', line 79

def construct_event(payload:, signature:, secret:)
  unless verify_signature(payload: payload, signature: signature, secret: secret)
    raise Nfe::SignatureVerificationError, "Assinatura de webhook inválida."
  end

  decoded = parse_payload(payload)
  build_event(decoded)
end

.envelope_data(decoded) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



118
119
120
121
# File 'lib/nfe/webhook.rb', line 118

def envelope_data(decoded)
  candidate = decoded["payload"] || decoded["data"]
  candidate.is_a?(Hash) ? candidate : decoded
end

.first_string(hash, *keys) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



124
125
126
127
128
129
130
# File 'lib/nfe/webhook.rb', line 124

def first_string(hash, *keys)
  keys.each do |key|
    value = hash[key]
    return value if value.is_a?(String) && !value.empty?
  end
  nil
end

.nullable_string(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



133
134
135
# File 'lib/nfe/webhook.rb', line 133

def nullable_string(value)
  value.is_a?(String) ? value : nil
end

.parse_payload(payload) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



89
90
91
92
93
94
95
96
97
98
# File 'lib/nfe/webhook.rb', line 89

def parse_payload(payload)
  decoded = JSON.parse(payload.to_s)
  unless decoded.is_a?(Hash)
    raise Nfe::SignatureVerificationError, "Payload de webhook não decodificou para um objeto."
  end

  decoded
rescue JSON::ParserError
  raise Nfe::SignatureVerificationError, "Payload de webhook não é JSON válido."
end

.verify_signature(payload:, signature:, secret:) ⇒ Boolean

Verify an X-Hub-Signature value against the payload and secret using constant-time comparison. Returns true only on an exact HMAC-SHA1 match.

Never raises: any missing, malformed, wrong-algorithm, wrong-length, or non-hex input yields false.

Parameters:

  • payload (String)

    the raw, byte-exact request body.

  • signature (String, Array<String>, nil)

    the X-Hub-Signature value; a single-element Array (repeated-header shape) uses its first element.

  • secret (String, nil)

    the webhook secret.

Returns:

  • (Boolean)


52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/nfe/webhook.rb', line 52

def verify_signature(payload:, signature:, secret:)
  return false if secret.nil? || secret.to_s.empty?

  signature = signature.first if signature.is_a?(Array)
  return false if signature.nil?

  signature = signature.to_s
  return false if signature.empty?
  return false unless signature[0, SIGNATURE_PREFIX.length].to_s.downcase == SIGNATURE_PREFIX

  received = signature[SIGNATURE_PREFIX.length..].to_s.downcase
  return false unless HEX_RE.match?(received)

  expected = OpenSSL::HMAC.hexdigest("SHA1", secret, payload.to_s)
  OpenSSL.secure_compare(received, expected)
rescue StandardError
  false
end

Instance Method Details

#self?.build_eventNfe::WebhookEvent

Parameters:

  • decoded (Hash[untyped, untyped])

Returns:



12
# File 'sig/nfe/webhook.rbs', line 12

def self?.build_event: (Hash[untyped, untyped] decoded) -> Nfe::WebhookEvent

#self?.construct_eventNfe::WebhookEvent

Parameters:

  • payload: (String)
  • signature: (String, Array[String], nil)
  • secret: (String, nil)

Returns:



8
# File 'sig/nfe/webhook.rbs', line 8

def self?.construct_event: (payload: String, signature: (String | Array[String])?, secret: String?) -> Nfe::WebhookEvent

#self?.envelope_dataHash[untyped, untyped]

Parameters:

  • decoded (Hash[untyped, untyped])

Returns:

  • (Hash[untyped, untyped])


14
# File 'sig/nfe/webhook.rbs', line 14

def self?.envelope_data: (Hash[untyped, untyped] decoded) -> Hash[untyped, untyped]

#self?.first_stringString?

Parameters:

  • hash (Hash[untyped, untyped])
  • keys (String)

Returns:

  • (String, nil)


16
# File 'sig/nfe/webhook.rbs', line 16

def self?.first_string: (Hash[untyped, untyped] hash, *String keys) -> String?

#self?.nullable_stringString?

Parameters:

  • value (Object)

Returns:

  • (String, nil)


18
# File 'sig/nfe/webhook.rbs', line 18

def self?.nullable_string: (untyped value) -> String?

#self?.parse_payloadHash[untyped, untyped]

Parameters:

  • payload (Object)

Returns:

  • (Hash[untyped, untyped])


10
# File 'sig/nfe/webhook.rbs', line 10

def self?.parse_payload: (untyped payload) -> Hash[untyped, untyped]

#self?.verify_signatureBoolean

Parameters:

  • payload: (String)
  • signature: (String, Array[String], nil)
  • secret: (String, nil)

Returns:

  • (Boolean)


6
# File 'sig/nfe/webhook.rbs', line 6

def self?.verify_signature: (payload: String, signature: (String | Array[String])?, secret: String?) -> bool