July 24, 2026 · Tutorial
Add E-Signatures to Go (Golang) Applications via REST API (2026)
Go microservices value speed, clarity, and zero unnecessary dependencies. Here is how to embed e-signatures into your Golang backend using standard net/http, HMAC-SHA256 webhook verification, and GORM database persistence.
Founder, Signbee
TL;DR
You don't need heavy SDKs or complex OAuth setup to sign contracts in Go. Using standard library primitives (net/http, encoding/json, and crypto/hmac), you can dispatch signature requests, handle rate limits, securely verify incoming webhooks with subtle.ConstantTimeCompare, and persist state in GORM or standard database/sql in under 150 lines of idiomatic Go.
Prerequisites & Requirements
Why Go Microservices Prefer REST Primitives Over Heavy SDKs
The Golang community has long embraced simple, explicit architectures and minimal external dependency trees. When building microservices in Go, adding third-party SDKs for third-party SaaS tools often introduces dependency bloat, security risk, and rigid abstractions that complicate request tracing and context handling.
Legacy e-signature providers force developers to import multi-megabyte SDK repositories that require OAuth 2.0 JWT assertions, RSA key pair management, complex XML/JSON envelope builder patterns, and pre-configured PDF templates. In contrast, modern REST APIs allow you to dispatch contracts directly using Go's standard net/http client.
If you are coming from other language ecosystems, check out our existing guides for Node.js e-signature integration and Python e-signature REST integration. In this tutorial, we focus specifically on writing clean, production-ready, idiomatic Go.
Step 1: Defining Type-Safe Data Structures in Go
Strong static typing is one of Go's greatest strengths. Before making HTTP calls, define request and response payload structures annotated with standard json struct tags. This ensures clean serialization with json.Marshal and robust deserialization with json.Unmarshal.
package signbee
import "time"
// SendDocumentRequest defines the parameters required to dispatch a contract.
type SendDocumentRequest struct {
Markdown string `json:"markdown"`
RecipientName string `json:"recipient_name"`
RecipientEmail string `json:"recipient_email"`
ExpirationDays int `json:"expiration_days,omitempty"`
}
// SendDocumentResponse defines the structured response returned by Signbee.
type SendDocumentResponse struct {
DocumentID string `json:"document_id"`
SigningURL string `json:"signing_url"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
// APIErrorResponse represents an error payload returned by non-2xx responses.
type APIErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
Code int `json:"code,omitempty"`
}
// WebhookEvent represents an incoming real-time signature event.
type WebhookEvent struct {
Event string `json:"event"`
Timestamp string `json:"timestamp"`
Data EventPayload `json:"data"`
}
// EventPayload contains details regarding document updates.
type EventPayload struct {
DocumentID string `json:"document_id"`
SignerName string `json:"signer_name"`
SignerEmail string `json:"signer_email"`
SignedPDFURL string `json:"signed_pdf_url,omitempty"`
}Step 2: Building a Production-Grade `net/http` Client
In production Go services, never rely on http.DefaultClient because it lacks default request timeouts, exposing your application to goroutine leaks if a remote endpoint hangs. Instead, construct a custom http.Client configured with an explicit timeout duration (e.g., 30 seconds) and accept a context.Context parameter to enable cancellation and request tracing.
Here is a complete, runnable Go client implementation handling Bearer authorization, status code validation, rate limit awareness (HTTP 429), and memory management via defer resp.Body.Close():
package signbee
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Client wraps HTTP operations for the Signbee REST API.
type Client struct {
APIKey string
BaseURL string
HTTPClient *http.Client
}
// NewClient returns a configured Signbee API client instance.
func NewClient(apiKey string) *Client {
return &Client{
APIKey: apiKey,
BaseURL: "https://signb.ee/api/v1",
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// SendDocument dispatches a Markdown document for e-signature.
func (c *Client) SendDocument(ctx context.Context, req SendDocumentRequest) (*SendDocumentResponse, error) {
jsonBytes, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("signbee: failed to marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.BaseURL+"/send",
bytes.NewReader(jsonBytes),
)
if err != nil {
return nil, fmt.Errorf("signbee: failed to construct request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("signbee: network request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("signbee: failed to read response body: %w", err)
}
// Handle Rate Limiting (HTTP 429)
if resp.StatusCode == http.StatusTooManyRequests {
retryAfter := resp.Header.Get("Retry-After")
return nil, fmt.Errorf("signbee: rate limited (429), retry after %s seconds: %s", retryAfter, string(respBody))
}
// Handle Non-2xx Status Codes
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var apiErr APIErrorResponse
if err := json.Unmarshal(respBody, &apiErr); err == nil && apiErr.Message != "" {
return nil, fmt.Errorf("signbee API error (%d): %s", resp.StatusCode, apiErr.Message)
}
return nil, fmt.Errorf("signbee API returned status %d: %s", resp.StatusCode, string(respBody))
}
var result SendDocumentResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("signbee: failed to decode success payload: %w", err)
}
return &result, nil
}Notice how the code reads the entire response body into memory with io.ReadAll before unmarshaling. This allows your code to inspect raw error messages even when the JSON response structure differs from expectations during error conditions.
Step 3: Secure Webhook Verification (HMAC-SHA256)
To receive asynchronous document state changes (such as document.signed, document.viewed, or document.declined), you must expose a public HTTP endpoint. For an extensive overview of event payloads and retry logic, consult our dedicated guide on e-signature API webhook events.
To prevent payload tampering or spoofing from malicious actors, every webhook notification sent by Signbee includes an X-Signbee-Signature header. You must calculate an HMAC-SHA256 hash of the unparsed HTTP request body using your secret key and verify it against the header.
crypto/subtle.ConstantTimeCompare when validating hash signatures in Go. Standard string comparison operators (==) terminate early on the first mismatched character, creating a subtle timing side-channel attack vector.1. Standard `net/http` Webhook Listener
Here is a complete, standard library HTTP handler function verifying signature headers and processing webhook payloads:
package signbee
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"io"
"net/http"
)
// VerifyWebhookSignature checks if headerSig matches HMAC-SHA256 of payload.
func VerifyWebhookSignature(payload []byte, headerSig string, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expectedSig := hex.EncodeToString(mac.Sum(nil))
if len(headerSig) != len(expectedSig) {
return false
}
return subtle.ConstantTimeCompare([]byte(headerSig), []byte(expectedSig)) == 1
}
// HTTPWebhookHandler constructs an http.HandlerFunc for standard net/http servers.
func HTTPWebhookHandler(webhookSecret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Unable to read body", http.StatusBadRequest)
return
}
signatureHeader := r.Header.Get("X-Signbee-Signature")
if !VerifyWebhookSignature(body, signatureHeader, webhookSecret) {
http.Error(w, "Invalid HMAC signature", http.StatusUnauthorized)
return
}
var event WebhookEvent
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
return
}
switch event.Event {
case "document.signed":
// Trigger application logic for signed agreement
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"processed"}`))
case "document.viewed":
// Track recipient activity
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusOK)
}
}
}2. Gin Framework Webhook Listener
If your application uses the popular Gin web framework, here is the equivalent handler implementation:
package main
import (
"io"
"net/http"
"github.com/gin-gonic/gin"
"yourproject/signbee"
)
func RegisterGinWebhookRoute(router *gin.Engine, secret string) {
router.POST("/webhooks/signbee", func(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot read request body"})
return
}
signature := c.GetHeader("X-Signbee-Signature")
if !signbee.VerifyWebhookSignature(body, signature, secret) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized signature"})
return
}
var event signbee.WebhookEvent
if err := json.Unmarshal(body, &event); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Malformed JSON payload"})
return
}
if event.Event == "document.signed" {
// Update local state or notify user
}
c.JSON(http.StatusOK, gin.H{"status": "received"})
})
}Step 4: Database Persistence with GORM & `database/sql`
Production implementations must record outgoing signature requests and track status updates when webhooks arrive. Below are examples for both GORM (the most popular Go ORM) and raw database/sql queries.
1. GORM Model & Repository Pattern
Define a DocumentRecord struct with index tags to quickly query pending documents:
package db
import (
"context"
"fmt"
"time"
"gorm.io/gorm"
)
// DocumentRecord represents the persistent state of an e-signature request.
type DocumentRecord struct {
ID uint `gorm:"primaryKey"`
DocumentID string `gorm:"uniqueIndex;not null"`
RecipientName string `gorm:"not null"`
RecipientEmail string `gorm:"not null"`
Status string `gorm:"index;not null;default:'pending'"`
SigningURL string `gorm:"not null"`
SignedPDFURL string `gorm:"default:''"`
CreatedAt time.Time
UpdatedAt time.Time
}
type GORMStore struct {
db *gorm.DB
}
func NewGORMStore(db *gorm.DB) *GORMStore {
return &GORMStore{db: db}
}
// CreateDocument inserts a new signature record upon dispatch.
func (s *GORMStore) CreateDocument(ctx context.Context, doc *DocumentRecord) error {
if err := s.db.WithContext(ctx).Create(doc).Error; err != nil {
return fmt.Errorf("gorm: failed to save document record: %w", err)
}
return nil
}
// UpdateDocumentStatus updates status and PDF URL upon receiving webhooks.
func (s *GORMStore) UpdateDocumentStatus(ctx context.Context, docID string, status string, pdfURL string) error {
err := s.db.WithContext(ctx).Model(&DocumentRecord{}).
Where("document_id = ?", docID).
Updates(map[string]interface{}{
"status": status,
"signed_pdf_url": pdfURL,
"updated_at": time.Now(),
}).Error
if err != nil {
return fmt.Errorf("gorm: failed to update document status: %w", err)
}
return nil
}2. Standard `database/sql` Pattern (PostgreSQL / SQLite)
For services avoiding ORMs to maximize performance, here is a clean repository using standard database/sql:
package db
import (
"context"
"database/sql"
"fmt"
"time"
)
type SQLStore struct {
db *sql.DB
}
func NewSQLStore(db *sql.DB) *SQLStore {
return &SQLStore{db: db}
}
func (s *SQLStore) UpdateStatus(ctx context.Context, docID, status, pdfURL string) error {
query := `
UPDATE document_records
SET status = $1, signed_pdf_url = $2, updated_at = $3
WHERE document_id = $4
`
result, err := s.db.ExecContext(ctx, query, status, pdfURL, time.Now(), docID)
if err != nil {
return fmt.Errorf("sql: failed executing update query: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("sql: no document record found with ID %s", docID)
}
return nil
}Comparing Language Integrations Side-by-Side
Here is a breakdown of how e-signature REST integrations compare across major backend languages and frameworks:
| Language / Stack | Primary Library | HMAC Signature Verification | Binary & Memory Footprint |
|---|---|---|---|
| Go (Golang) | net/http (Standard Library) | subtle.ConstantTimeCompare | Minimal (~15MB compiled binary) |
| Node.js | fetch / axios | crypto.timingSafeEqual | Medium (requires Node runtime) |
| Python | requests / httpx | hmac.compare_digest | Medium (requires virtualenv dependencies) |
| DocuSign Go SDK | docusign-esign-client (Deprecated) | Custom Connect Signatures | Heavy (Complex OAuth & RSA deps) |
Frequently Asked Questions
How do I send a document for e-signature programmatically in Go (Golang)?
To send a document for e-signature programmatically in Go (Golang), you can use the standard net/http package without installing external third-party SDKs. You construct a JSON payload containing the Markdown content of your agreement along with recipient information (such as name and email address). After encoding the payload with json.Marshal, issue an HTTP POST request to the Signbee API endpoint at https://signb.ee/api/v1/send. Set the Authorization header with a Bearer API token generated from your Signbee developer dashboard. Always ensure proper HTTP client timeout configurations using http.Client{Timeout: 30 * time.Second} and defer closing response.Body. The API converts your Markdown contract into a rendered PDF, emails the signing link to the recipient, and returns a JSON payload containing a document_id and signing_url for tracking or embedding directly into your application's user interface.
How do I verify e-signature webhooks in Go to prevent payload tampering?
Validating incoming webhooks in Go requires computing an HMAC-SHA256 signature of the raw request payload using your Signbee webhook signing secret and comparing it against the incoming X-Signbee-Signature header. In Go, read the full request body using io.ReadAll, construct an HMAC hash using crypto/hmac and crypto/sha256, and format the calculated hash byte slice as a hex string via encoding/hex. Finally, compare the expected signature hex string with the header value using subtle.ConstantTimeCompare rather than standard string comparison operators. Using constant-time comparison is vital in production Go applications because it eliminates timing side-channel attacks that could allow attackers to forge webhook signatures. Returning an HTTP 401 Unauthorized status code when verification fails ensures unauthenticated requests are discarded before executing downstream database transactions.
Why is net/http preferred over dedicated e-signature SDKs in Golang microservices?
Go emphasizes minimalism, fast compile times, zero extra dependencies, and clear operational footprints. Legacy e-signature platforms like DocuSign require heavy third-party SDKs that introduce dozens of indirect dependencies, complex OAuth 2.0 JWT token assertions with RSA key management, and verbose multi-step envelope construction. In contrast, modern developer-first APIs like Signbee use simple Bearer token authentication and single-endpoint document dispatch. Using Go's built-in net/http, encoding/json, and crypto packages allows developers to maintain complete control over HTTP client settings, request context timeouts, retry logic, and memory allocation. This approach keeps binary sizes small, speeds up deployment pipelines, eliminates supply-chain vulnerability risks, and aligns perfectly with idiomatic Go backend design patterns.
Ready to integrate e-signatures into your Go backend? Get 5 free documents/month.
Last updated: July 24, 2026 · Michael Beckett is the founder of Signbee and B2bee Ltd.