Skip to content

Go

Call Formerie from any Go server with net/http, from the standard library, with nothing to install. You don’t need a Formerie package or an API key: a form’s public address is all it takes.

Submit a form

Save this as main.go, and run go run main.go.

Go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"
)

func main() {
	runtimeURL := os.Getenv("FORMERIE_RUNTIME_URL")
	if runtimeURL == "" {
		runtimeURL = "https://forms.formerie.com"
	}
	// Your form. This is the only line that names it.
	form := os.Getenv("FORMERIE_FORM")
	if form == "" {
		form = "acme/contact"
	}

	body, err := json.Marshal(map[string]any{
		"fields": map[string]any{
			"name":    "Jane Example",
			"email":   "jane@example.com",
			"message": "Hello from my server",
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	client := &http.Client{Timeout: 10 * time.Second}
	response, err := client.Post(runtimeURL+"/"+form+"/submit", "application/json", bytes.NewReader(body))
	if err != nil {
		log.Fatal(err)
	}
	defer response.Body.Close()

	var result struct {
		OK            bool   `json:"ok"`
		SubmissionUID string `json:"submissionUid"`
		Error         struct {
			Code string `json:"code"`
		} `json:"error"`
	}
	if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
		log.Fatal(err)
	}
	fmt.Println(response.StatusCode, result.SubmissionUID, result.Error.Code)
}

Replace acme/contact, on the marked line, with your own handles. A success is HTTP 202, with a submissionUid such as sub_p6dr8wq2j7tkan: the answers are stored, and their email is queued. Each sample reads two optional settings from the environment, FORMERIE_RUNTIME_URL for a hostname you’ve connected, and FORMERIE_FORM for the handles.

Before you start

  1. Publish a form, and take its workspace and form handles from the Publish tab. Your server doesn’t need to be in the form’s allowed websites.
  2. To try things without emailing anyone, create a test key on the form’s Tests tab, and put it in an environment variable called FORMERIE_TEST_KEY:
Go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"
)

func main() {
	runtimeURL := os.Getenv("FORMERIE_RUNTIME_URL")
	if runtimeURL == "" {
		runtimeURL = "https://forms.formerie.com"
	}
	// Your form. This is the only line that names it.
	form := os.Getenv("FORMERIE_FORM")
	if form == "" {
		form = "acme/contact"
	}

	body, err := json.Marshal(map[string]any{
		"fields": map[string]any{"name": "Jane Example", "email": "jane@example.com", "message": "Just a test"},
	})
	if err != nil {
		log.Fatal(err)
	}
	request, err := http.NewRequest(http.MethodPost, runtimeURL+"/"+form+"/submit", bytes.NewReader(body))
	if err != nil {
		log.Fatal(err)
	}
	request.Header.Set("Content-Type", "application/json")
	// Keep the key out of your code. A test is stored apart and emails nobody.
	request.Header.Set("X-Formerie-Test-Key", os.Getenv("FORMERIE_TEST_KEY"))

	client := &http.Client{Timeout: 10 * time.Second}
	response, err := client.Do(request)
	if err != nil {
		log.Fatal(err)
	}
	defer response.Body.Close()

	var result struct {
		OK            bool   `json:"ok"`
		SubmissionUID string `json:"submissionUid"`
		Error         struct {
			Code string `json:"code"`
		} `json:"error"`
	}
	if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
		log.Fatal(err)
	}
	fmt.Println(response.StatusCode, result.SubmissionUID, result.Error.Code)
}

A test comes back as tst_..., is stored apart, and emails nobody. A wrong key gets TEST_KEY_INVALID, HTTP 401, and nothing is stored. Remove the header before you go live, since a key left in place files everything as a test. See Test from a server or CI.

Check answers without submitting

validate checks answers and stores nothing, so you can flag a problem early. It answers 200 when they pass, and 422 with the same errors as a submission when they don’t. The final submission always checks the whole form again.

Go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

var client = &http.Client{Timeout: 10 * time.Second}

type issue struct {
	Path    string `json:"path"`
	Code    string `json:"code"`
	Message string `json:"message"`
}

type checked struct {
	OK     bool           `json:"ok"`
	Values map[string]any `json:"values"`
	Error  struct {
		Code   string             `json:"code"`
		Fields map[string][]issue `json:"fields"`
	} `json:"error"`
}

func formURL(action string) string {
	runtimeURL := os.Getenv("FORMERIE_RUNTIME_URL")
	if runtimeURL == "" {
		runtimeURL = "https://forms.formerie.com"
	}
	// Your form. This is the only line that names it.
	form := os.Getenv("FORMERIE_FORM")
	if form == "" {
		form = "acme/contact"
	}
	return runtimeURL + "/" + form + "/" + action
}

// checkAnswers stores nothing. With no step, only the fields you send are checked.
func checkAnswers(fields map[string]any, step string) (*checked, error) {
	payload := map[string]any{"mode": "partial", "fields": fields}
	if step != "" {
		payload = map[string]any{"mode": "step", "step": step, "fields": fields}
	}
	body, err := json.Marshal(payload)
	if err != nil {
		return nil, err
	}
	response, err := client.Post(formURL("validate"), "application/json", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	defer response.Body.Close()

	var result checked
	if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
		return nil, err
	}
	if !result.OK && result.Error.Code != "VALIDATION_FAILED" {
		return nil, fmt.Errorf("formerie: %s", result.Error.Code)
	}
	return &result, nil
}

By default, only the fields you send are checked. Pass a step to check every field in that step instead, on a form with steps. A step the form doesn’t have gets BAD_REQUEST.

Show errors per field

Answers that fail come back as VALIDATION_FAILED, HTTP 422, and nothing is stored. error.fields holds the problems by field, and error.issues holds the same ones in order. Each has a path, a code to match on, and a message that’s safe to show, in the form’s language.

Go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"html"
	"net/http"
	"os"
	"strings"
	"time"
)

var client = &http.Client{Timeout: 10 * time.Second}

type issue struct {
	Path    string `json:"path"`
	Code    string `json:"code"`
	Message string `json:"message"`
}

type submitResult struct {
	OK    bool `json:"ok"`
	Error struct {
		Code   string             `json:"code"`
		Fields map[string][]issue `json:"fields"`
	} `json:"error"`
}

func formURL(action string) string {
	runtimeURL := os.Getenv("FORMERIE_RUNTIME_URL")
	if runtimeURL == "" {
		runtimeURL = "https://forms.formerie.com"
	}
	// Your form. This is the only line that names it.
	form := os.Getenv("FORMERIE_FORM")
	if form == "" {
		form = "acme/contact"
	}
	return runtimeURL + "/" + form + "/" + action
}

func submitAnswers(fields map[string]any) (*submitResult, error) {
	body, err := json.Marshal(map[string]any{"fields": fields})
	if err != nil {
		return nil, err
	}
	response, err := client.Post(formURL("submit"), "application/json", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	defer response.Body.Close()

	var result submitResult
	if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
		return nil, err
	}
	return &result, nil
}

// answerAt finds the visitor's answer for a path such as contact.email, so the form can show it again.
func answerAt(answers map[string]any, path string) string {
	var value any = answers
	for _, part := range strings.Split(path, ".") {
		group, ok := value.(map[string]any)
		if !ok {
			return ""
		}
		value = group[part]
	}
	if value == nil {
		return ""
	}
	return fmt.Sprint(value)
}

// renderField is one input with its answer kept and its first message under it.
func renderField(path string, answers map[string]any, result *submitResult) string {
	field := fmt.Sprintf(`<input name="%s" value="%s">`, html.EscapeString(path), html.EscapeString(answerAt(answers, path)))
	if issues := result.Error.Fields[path]; len(issues) > 0 {
		field += fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(issues[0].Message))
	}
	return field
}

A field inside a group has a path such as contact.email, and one inside a list has guests[0].email, so split the path to find its answer. See Field paths. Show the visitor’s answers again with the messages, escaped, so they only have to fix what’s wrong.

Retry safely

A network failure doesn’t tell you whether the submission arrived. So send one metadata.idempotencyKey per submission, the same on every retry. Formerie then counts and delivers it once, and answers a repeat with DUPLICATE_SUBMISSION, HTTP 409, which means the first one worked.

Go
package main

import (
	"bytes"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"math/big"
	"net/http"
	"os"
	"strconv"
	"time"
)

const (
	maxAttempts = 3
	longestWait = 30 // seconds
)

var client = &http.Client{Timeout: 10 * time.Second}

type issue struct {
	Path    string `json:"path"`
	Code    string `json:"code"`
	Message string `json:"message"`
}

type outcome struct {
	Outcome   string  `json:"outcome"`
	ID        string  `json:"id,omitempty"`
	Duplicate bool    `json:"duplicate,omitempty"`
	Reason    string  `json:"reason,omitempty"`
	After     int     `json:"after,omitempty"`
	Code      string  `json:"code,omitempty"`
	Message   string  `json:"message,omitempty"`
	Issues    []issue `json:"issues,omitempty"`
	Key       string  `json:"key"`
	Attempts  int     `json:"attempts"`
	again     bool
}

func submitURL() string {
	runtimeURL := os.Getenv("FORMERIE_RUNTIME_URL")
	if runtimeURL == "" {
		runtimeURL = "https://forms.formerie.com"
	}
	// Your form. This is the only line that names it.
	form := os.Getenv("FORMERIE_FORM")
	if form == "" {
		form = "acme/contact"
	}
	return runtimeURL + "/" + form + "/submit"
}

// sendOnce is one attempt. It says what happened, and whether another attempt could help.
func sendOnce(fields map[string]any, key string) outcome {
	body, err := json.Marshal(map[string]any{
		"fields":   fields,
		"metadata": map[string]any{"idempotencyKey": key},
	})
	if err != nil {
		return outcome{Outcome: "rejected", Reason: "bad-answers"}
	}
	response, err := client.Post(submitURL(), "application/json", bytes.NewReader(body))
	if err != nil {
		return outcome{Outcome: "retry-later", Reason: "network", again: true}
	}
	defer response.Body.Close()

	// Only the short-term limit sends Retry-After. A form's own limit can last an hour, so don't wait for it.
	if response.StatusCode == http.StatusTooManyRequests {
		after, err := strconv.Atoi(response.Header.Get("Retry-After"))
		return outcome{Outcome: "retry-later", Reason: "rate-limited", After: after, again: err == nil && after <= longestWait}
	}

	data, err := io.ReadAll(response.Body)
	if err != nil {
		return outcome{Outcome: "retry-later", Reason: "network", again: true}
	}
	var result struct {
		OK            bool   `json:"ok"`
		SubmissionUID string `json:"submissionUid"`
		Error         struct {
			Code    string  `json:"code"`
			Message string  `json:"message"`
			Issues  []issue `json:"issues"`
		} `json:"error"`
	}
	// Something in front of Formerie may answer without JSON.
	if err := json.Unmarshal(data, &result); err != nil {
		temporary := response.StatusCode == 502 || response.StatusCode == 503 || response.StatusCode == 504
		return outcome{Outcome: "retry-later", Reason: "bad-response", again: temporary}
	}

	if result.OK {
		return outcome{Outcome: "accepted", ID: result.SubmissionUID}
	}
	switch result.Error.Code {
	case "DUPLICATE_SUBMISSION":
		// The first send worked, and only its answer was lost.
		return outcome{Outcome: "accepted", Duplicate: true}
	case "FORM_MAINTENANCE", "QUEUE_FAILED", "INTERNAL_ERROR":
		return outcome{Outcome: "retry-later", Reason: result.Error.Code, again: true}
	case "VALIDATION_FAILED":
		return outcome{Outcome: "invalid", Code: result.Error.Code, Issues: result.Error.Issues}
	}
	return outcome{Outcome: "rejected", Code: result.Error.Code, Message: result.Error.Message}
}

func newKey() (string, error) {
	bytes := make([]byte, 16)
	if _, err := rand.Read(bytes); err != nil {
		return "", err
	}
	return hex.EncodeToString(bytes), nil
}

// backoff is a random wait of up to 500 ms, then 1 s, and at most 4 s.
func backoff(attempt int) time.Duration {
	longest := int64(250*time.Millisecond) << attempt
	if longest > int64(4*time.Second) {
		longest = int64(4 * time.Second)
	}
	wait, err := rand.Int(rand.Reader, big.NewInt(longest))
	if err != nil {
		return time.Duration(longest)
	}
	return time.Duration(wait.Int64())
}

// submitWithRetries takes one key per submission, the same on every retry. Store it with the answers if you retry later.
func submitWithRetries(fields map[string]any, key string) outcome {
	for attempt := 1; ; attempt++ {
		answer := sendOnce(fields, key)
		if !answer.again || attempt == maxAttempts {
			answer.Key, answer.Attempts = key, attempt
			return answer
		}
		wait := backoff(attempt)
		if after := time.Duration(answer.After) * time.Second; after > wait {
			wait = after
		}
		time.Sleep(wait)
	}
}
AnswerRetry?
No connection, or a timeoutYes, with the same key
429 with Retry-AfterYes, after that wait, if it’s short. The short-term limit answers with Retry-After: 60
429 with no Retry-AfterNo. A form’s own limit can last an hour, so ask the visitor to try again later
FORM_MAINTENANCE, QUEUE_FAILED, INTERNAL_ERRORYes, after a short wait
502, 503 or 504 with no JSONYes. Something in front of Formerie answered
Any other answer with no JSONNo. Keep the key, and look into it
DUPLICATE_SUBMISSIONNo. Treat it as a success
VALIDATION_FAILED and any other codeNo. The answer won’t change
  • The key must be a non-empty string. Anything else is ignored, and there’s no repeat check.
  • A repeat is noticed for 300 seconds by default, under the form’s repeat window. See What the repeat window covers.
  • The check comes first, so a key that went through gets DUPLICATE_SUBMISSION even with different answers. Use a new key for each new submission. A key refused for its answers was never accepted, so it can go again once they’re fixed.
  • A retry after QUEUE_FAILED can store a second copy, which you’d see under Submissions. It’s rare.

Human verification

If your form requires human verification, use your own keys, with your site in the provider’s list of domains and in the form’s allowed websites. The widget adds its token to your form. Forward two things:

  1. The token, as verificationToken, taken out of the fields.
  2. The Origin of the visitor’s request, because Formerie compares the token’s domain with it. A server sends none by itself, and the check then fails with TURNSTILE_FAILED.
Go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

var client = &http.Client{Timeout: 10 * time.Second}

type submitResult struct {
	OK            bool   `json:"ok"`
	SubmissionUID string `json:"submissionUid"`
	Error         struct {
		Code string `json:"code"`
	} `json:"error"`
}

func submitURL() string {
	runtimeURL := os.Getenv("FORMERIE_RUNTIME_URL")
	if runtimeURL == "" {
		runtimeURL = "https://forms.formerie.com"
	}
	// Your form. This is the only line that names it.
	form := os.Getenv("FORMERIE_FORM")
	if form == "" {
		form = "acme/contact"
	}
	return runtimeURL + "/" + form + "/submit"
}

// submitVerified takes what your form sent, and the Origin header of the visitor's request.
func submitVerified(posted map[string]any, origin string) (*submitResult, error) {
	fields := map[string]any{}
	for name, value := range posted {
		if name != "cf-turnstile-response" {
			fields[name] = value
		}
	}
	body, err := json.Marshal(map[string]any{"fields": fields, "verificationToken": posted["cf-turnstile-response"]})
	if err != nil {
		return nil, err
	}
	request, err := http.NewRequest(http.MethodPost, submitURL(), bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	request.Header.Set("Content-Type", "application/json")
	// Without the visitor's Origin, the provider's hostname can't match, and the check fails.
	if origin != "" {
		request.Header.Set("Origin", origin)
	}
	response, err := client.Do(request)
	if err != nil {
		return nil, err
	}
	defer response.Body.Close()

	var result submitResult
	if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
		return nil, err
	}
	return &result, nil
}

An Origin that isn’t in the form’s allowed websites gets FORBIDDEN_ORIGIN. Only forward it for this: on any other form, it turns on a check your server doesn’t otherwise need. The widget needs JavaScript in the visitor’s browser. See Verification on the server path.

Spam checks

Hosted pages and embeds send this metadata for you. From your server, send it yourself, or those checks are skipped.

Go
package main

import (
	"bytes"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strconv"
	"time"
)

var client = &http.Client{Timeout: 10 * time.Second}

type submitResult struct {
	OK            bool   `json:"ok"`
	SubmissionUID string `json:"submissionUid"`
	Error         struct {
		Code string `json:"code"`
	} `json:"error"`
}

func submitURL() string {
	runtimeURL := os.Getenv("FORMERIE_RUNTIME_URL")
	if runtimeURL == "" {
		runtimeURL = "https://forms.formerie.com"
	}
	// Your form. This is the only line that names it.
	form := os.Getenv("FORMERIE_FORM")
	if form == "" {
		form = "acme/contact"
	}
	return runtimeURL + "/" + form + "/submit"
}

func submitWithMetadata(fields map[string]any, startedAt time.Time, visitorID string) (*submitResult, error) {
	key := make([]byte, 16)
	if _, err := rand.Read(key); err != nil {
		return nil, err
	}
	body, err := json.Marshal(map[string]any{
		"fields": fields,
		"metadata": map[string]any{
			"idempotencyKey": hex.EncodeToString(key),
			// When the visitor opened the form, for the minimum submit time check.
			"startedAt": startedAt.UTC().Format(time.RFC3339),
			"pageUrl":   "https://www.example.com/contact",
			// Your own visitor ID, for a limit rule with the deviceToken scope.
			"deviceToken": visitorID,
		},
	})
	if err != nil {
		return nil, err
	}
	response, err := client.Post(submitURL(), "application/json", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	defer response.Body.Close()

	var result submitResult
	if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
		return nil, err
	}
	return &result, nil
}
FieldWhat it’s for
idempotencyKeyThe repeat check, as above
startedAtWhen the visitor opened the form, as an ISO 8601 time. Sent sooner than the form’s minimum, 1 second by default, it gets SPAM_REJECTED. Keep the real time from when you showed the form, not the time of this call. One that can’t be read skips the check
pageUrlThe page the form was sent from. Stored only if it’s an http or https address
deviceTokenYour own visitor ID, for a limit per visitor, below

The hidden field check runs when your form sends that field, and links and human verification run on the server either way. See Spam and limits.

Limits from your server

Formerie counts visitors by network address, and from here, that’s your server’s. Every visitor counts as your server.

LimitDefaultFrom your server
Per visitor30 submissions every 10 minutesShared by all your visitors. Raise it before you launch, or leave it empty for no limit, under Settings, then Submission limits
Whole form1,000 submissions an hourThe same. On the Free plan, it’s also the most a form can accept
Short-term120 requests a minute for each form from one addressCan’t be changed. It’s the only one that sends Retry-After. validate counts here too

Over any of them, you get RATE_LIMITED, HTTP 429. To keep a limit for each visitor, a rule with the deviceToken scope counts by the ID you send, such as a hash of the visitor’s address. The app’s settings don’t offer that rule yet, so talk to us to set one up, and keep the whole-form limit, since anyone calling the form directly can change their ID. See Limits on the server path for the reasoning, and Plans and limits for monthly counts.

Good to know

  • http.DefaultClient never times out, so make your own http.Client with a Timeout, and reuse it.
  • Keep test keys and answers out of your logs. A requestId in an error, or the X-Request-Id header, is what to log and to quote when you ask for help.
  • Match on error.code, never on the message, since a form can reword its messages and a status can cover several codes. Error codes lists them all.
WithNote
Your handlerCall request.ParseForm(), then read request.PostForm for answers, and request.Header.Get("Origin") for the visitor’s Origin.
Using severalThe first two blocks are whole programs, and the others are functions to add to one. Keep one client and one formURL.

The routes, fields and every error are in the API reference.