Files
zeekay 73b7290e7f square: one webhook route and one verifier; secrets come from the host's KMS
Square keeps an event's created_at across every retry, and the receiver
refused anything older than five minutes, so every retry of an event
first sent while cloud was down (Recreate, ten rollouts a day) was refused
until Square gave up. The verifier now lives in payment/providers/square
and needs only the key and the URL; thirdparty/square is the API client
and no longer registers itself or verifies anything, so every registry
holds one Square type.

Replay is stopped by the event id instead: each verified event is
recorded once for the whole deployment, keyed on the verifying processor
and the event id with an insert-if-absent swap, so a repeat is a
duplicate under any path or org. The path names the one processor that
verifies, and only one that holds its webhook secret may: an unknown name,
or a verifier that needs no secret, is refused rather than tried.
The books an event lands in are the verifying account's, else the
deployment's brand — never a header or session, which on a replay are the
replayer's. An effect that did not land (a store or the ledger did not
answer) leaves the delivery open with a 503, so the provider redelivers;
it used to be acknowledged and never retried. A settlement reads the
payment index before the org's books, because an in-session charge's
receipt is in the payer's org.

POST /v1/commerce/webhooks/:provider is declared once, in setupRoutes, so
standalone and embedded commerce serve the same address; the
/v1/billing/webhooks spelling is gone.

thirdparty/kms read /v1/kms/orgs/<org>/secrets/..., which cloud no longer
serves, and was off in production anyway, so per-org payment credentials
never loaded. It is deleted. secrets.Org reads orgs/<org>/<name> through
the host's reader, the caller re-pointed at that org with zip.ActingAs so
the KMS tenancy check admits it, and payment.Hydrate loads an org's
credentials where the org receives the money: storefront checkout,
storefront subscriptions and wire, and the brand's bank for the billing
wire rail. Hanzo's own billing no longer reads the paying org's
credentials; it charges the deployment's account, as it did. The
standalone binary reads through hanzoai/kms/sdk/go.
2026-10-19 09:12:56 -07:00

109 lines
3.9 KiB
Go

package square
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/hanzoai/commerce/payment/processor"
)
// ValidateWebhook is the one Square webhook verifier. HandleProviderWebhook
// (api/billing/webhooks.go) reaches it through processor.Global(), at the one
// route POST /v1/commerce/webhooks/:provider.
//
// Square signs each notification as
//
// base64(HMAC-SHA256(signatureKey, notificationURL + rawBody))
//
// where notificationURL is the EXACT URL of the webhook subscription — never one
// derived from the inbound request, which a proxy rewrites. The digests are
// compared as raw bytes with hmac.Equal.
//
// There is no freshness window, and that is deliberate. Square keeps an event's
// created_at across every retry of it (it retries a delivery that got no 2xx for
// up to 72 hours, and the dashboard can resend an event later than that), so a
// window rejects exactly the deliveries that matter most: the ones that arrived
// while the receiver was down. A five-minute window here made every retry of an
// event Square first sent during a deploy a permanent refusal. Replay is stopped
// where it can be stopped exactly — the receiver records each event id once and
// acknowledges a repeat as a duplicate, and every money posting downstream is
// keyed on the provider's payment, refund or dispute id.
//
// Verifying needs only the signature key and the notification URL; it does not
// need the charge credentials, so a deployment that verifies but does not charge
// still verifies.
func (p *Provider) ValidateWebhook(ctx context.Context, payload []byte, signature string) (*processor.WebhookEvent, error) {
key, url := p.config.WebhookSignatureKey, p.config.WebhookURL
if key == "" {
return nil, fmt.Errorf("%w: square webhook signature key not configured", processor.ErrWebhookValidationFailed)
}
if url == "" {
return nil, fmt.Errorf("%w: square webhook URL not configured", processor.ErrWebhookValidationFailed)
}
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(url))
mac.Write(payload)
provided, err := base64.StdEncoding.DecodeString(strings.TrimSpace(signature))
if err != nil || !hmac.Equal(provided, mac.Sum(nil)) {
return nil, processor.ErrWebhookValidationFailed
}
var evt struct {
Type string `json:"type"`
EventID string `json:"event_id"`
CreatedAt string `json:"created_at"`
Data struct {
Object map[string]interface{} `json:"object"`
} `json:"data"`
}
if err := json.Unmarshal(payload, &evt); err != nil {
return nil, fmt.Errorf("square webhook: %w", err)
}
if evt.EventID == "" {
// The event id is the receiver's whole replay defence, so a signed body
// without one is refused rather than processed unkeyed.
return nil, fmt.Errorf("%w: square webhook carries no event_id", processor.ErrWebhookValidationFailed)
}
ts := time.Now().Unix()
if t, err := time.Parse(time.RFC3339, evt.CreatedAt); err == nil {
ts = t.Unix()
}
return &processor.WebhookEvent{
ID: evt.EventID,
Type: eventType(evt.Type),
Processor: processor.Square,
Org: p.config.Org,
Data: evt.Data.Object,
Timestamp: ts,
}, nil
}
// ReceivesWebhooks reports whether this account can verify a delivery: it holds
// the subscription's signature key and its notification URL.
func (p *Provider) ReceivesWebhooks() bool {
return p.config.WebhookSignatureKey != "" && p.config.WebhookURL != ""
}
var _ processor.WebhookReceiver = (*Provider)(nil)
// eventType names a Square event in the receiver's vocabulary. Only the names the
// receiver branches on differ from Square's own: an invoice payment settles as
// invoice.paid, and a dispute's change of state is dispute.updated under either of
// the names Square has used for it.
func eventType(t string) string {
switch t {
case "invoice.payment_made":
return "invoice.paid"
case "dispute.state.updated", "dispute.state.changed":
return "dispute.updated"
}
return t
}