outstand

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 24, 2026 License: MIT Imports: 13 Imported by: 0

README

outstand-go

A small, dependency-free Go client for the Outstand social-posting API, plus its webhook-verification and fan-out-policy helpers. Shared across Pigfox products — one vocabulary of apps and connected social-account ids spans every integration.

  • Stdlib only. No third-party dependencies, no go.sum.
  • Token-safe. A decoded Post is a strict allowlist; the vendor's embedded per-network OAuth tokens are never lifted into it.
  • 100% test coverage, race-checked, with a fuzzed webhook verifier.

Install

go get github.com/pigfox/outstand-go
import outstand "github.com/pigfox/outstand-go"

Create a post

client := outstand.NewClient(os.Getenv("OUTSTAND_API_KEY"))

post, err := client.CreatePost(ctx, outstand.CreatePostInput{
	AccountIDs: outstand.DefaultFanout[outstand.AppMarketVerdict], // GvKip, OSRXv
	Containers: []outstand.Container{{Content: "New on Market Verdict: …"}},
})
if err != nil {
	var apiErr *outstand.APIError
	if errors.As(err, &apiErr) {
		// vendor rejected the request: apiErr.Status, apiErr.Body (bounded excerpt)
	}
	return err
}
_ = post.ID

Accounts are targeted by Outstand account id (AccountID), never by network name. DefaultFanout holds the per-app policy; OwnedBy reports which app owns a given account.

Verify a webhook

event, err := outstand.ConstructEvent(rawBody, r.Header.Get("X-Outstand-Signature"), secret)
if err != nil {
	if outstand.IsSignatureError(err) {
		http.Error(w, "bad signature", http.StatusBadRequest) // 400 — do not ack
		return
	}
	w.WriteHeader(http.StatusOK) // authentic but unparseable: ack so Outstand stops retrying
	return
}
switch event.Type {
case outstand.EventPostPublished:
	// …
}

ConstructEvent computes a constant-time HMAC-SHA256 over the raw body against OUTSTAND_WEBHOOK_SECRET and only decodes on a verified signature.

Test

./tests.sh   # race + 100% coverage gate

License

MIT — see LICENSE.

Documentation

Overview

Package outstand is a stdlib-only client for the Outstand social-posting API (https://outstand.so), together with its webhook-verification and fan-out-policy helpers. It is shared across Pigfox products (pigfox, marketverdict): one vocabulary of apps and connected social-account ids spans every integration.

Client

NewClient returns a Client (a Gateway) bound to an OUTSTAND_API_KEY bearer token. It creates, fetches, and deletes posts and lists/health-checks connected social accounts. A decoded Post is a strict allowlist: the vendor's embedded per-network OAuth tokens are never lifted into it, so a token cannot reach a decoded value, a log line, an error, or the ledger.

Webhooks

ConstructEvent verifies an Outstand webhook delivery — a constant-time HMAC-SHA256 over the raw body against OUTSTAND_WEBHOOK_SECRET — and only then decodes it into an Event. IsSignatureError separates a signature/format rejection (answer 400) from a post-verification parse failure (ack 200, so Outstand does not retry a payload the decoder cannot read).

Identity and fan-out policy

DefaultFanout maps an App to the AccountID set a default publish fans out to; OwnedBy reports which app owns an account (a foreign id on the shared Outstand organization is acked-and-ignored). These ids are non-secret and live in code as guard-testable publish policy — secrets never do.

Versioning

This module follows semantic versioning. New functionality arrives as additions; no exported name, signature, or documented behavior changes without a major-version bump.

Index

Constants

View Source
const (
	EventPostPublished       = "post.published"
	EventPostError           = "post.error"
	EventAccountTokenExpired = "account.token_expired"
	EventTest                = "test"
	EventImportCompleted     = "import.completed"
	EventImportFailed        = "import.failed"
)

Event type strings Outstand delivers (confirmed against the Outstand webhook docs, 2026-07). post.published fires when a post succeeded on at least one platform; post.error when it failed across all targeted accounts; account.token_expired when an OAuth refresh failed. "test" is the dashboard Test button. import.* are acked-and-ignored (no local effect in this service).

Variables

View Source
var (
	ErrEmptySecret     = errors.New("outstand webhook: empty signing secret")
	ErrMissingSig      = errors.New("outstand webhook: missing signature header")
	ErrMalformedSig    = errors.New(`outstand webhook: signature header not "sha256=<hex>"`)
	ErrSignatureFailed = errors.New("outstand webhook: signature mismatch")
)

Signature/format sentinels. IsSignatureError reports whether an error is one of these — the handler answers 400 for those and acks (200) any OTHER ConstructEvent error (an authentic body whose JSON we cannot parse), so Outstand does not retry a payload our decoder can't read.

View Source
var AccountEncoderField = FieldAccounts

AccountEncoderField is the LOCKED create-post account-id key, set from the STEP 2 empirical verification: the live API requires "accounts" (docs' socialAccountIds 400s). Recorded discrepancy — see the Directive-4 ledger note.

DefaultFanout maps an app to the social accounts a default publish fans out to. pigfox is WIRED; marketverdict is DEFINED but NOT referenced by any publish path (Directive 4+). The personal LinkedIn is intentionally absent from every set.

Functions

func IsSignatureError

func IsSignatureError(err error) bool

IsSignatureError reports whether err is a signature/format failure (→ 400) as opposed to a post-verification JSON parse failure (→ ack 200).

Types

type APIError

type APIError struct {
	Op     string
	Status int
	Body   string
}

APIError is the typed vendor error: the operation, the HTTP status, and a bounded body excerpt (never the full body, never a secret). Returned for any non-2xx.

func (*APIError) Error

func (e *APIError) Error() string

type Account

type Account struct {
	ID       AccountID
	Network  string
	Username string
	IsActive int
}

Account is a decoded social account (the subset this subsystem needs).

type AccountID

type AccountID string

AccountID is an Outstand social-account id. Non-secret and stable.

const (
	// Pigfox LLC — the default-fan-out identities.
	AccountPigfoxFacebook AccountID = "WBh2z" // Pigfox LLC (facebook page)
	AccountPigfoxLinkedIn AccountID = "yVFhj" // pigfox-llc (linkedin organization)

	// Market Verdict — reserved; defined but NOT wired to any publish path yet.
	AccountMVFacebook AccountID = "GvKip" // Market Verdict (facebook page)
	AccountMVLinkedIn AccountID = "OSRXv" // marketverdict-app (linkedin organization)

	// Peter Sjölin's personal LinkedIn — opt-in ONLY, per standing decision. It is
	// deliberately in NO DefaultFanout set; TestPersonalLinkedInNeverInDefaultFanout
	// enforces that.
	AccountPeterPersonalLinkedIn AccountID = "g35ix" // Peter Sjölin (linkedin personal)
)

type AccountResult

type AccountResult struct {
	AccountID      AccountID
	Status         string
	PlatformPostID string
	Error          string
}

AccountResult is one social account's outcome within a post.* event.

type App

type App string

App is the product discriminator, matching the locked Stripe metadata contract strings ("pigfox" / "marketverdict") so one vocabulary spans both integrations.

const (
	AppPigfox        App = "pigfox"
	AppMarketVerdict App = "marketverdict"
)

func OwnedBy

func OwnedBy(id AccountID) (App, bool)

OwnedBy returns the app owning a social-account id and whether it is one of ours at all. A foreign account (not in accountToApp) returns ("", false).

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is the real Gateway over net/http. Each instance owns its bearer key and a client with clientTimeout. do is a seam over the round-trip so tests never hit the network.

func NewClient

func NewClient(apiKey string) *Client

NewClient builds a Client bound to apiKey (Authorization: Bearer) against the production base URL.

func (*Client) CheckAccountHealth

func (c *Client) CheckAccountHealth(ctx context.Context, id AccountID) (bool, error)

CheckAccountHealth reports whether an account is live (isActive==1), derived from ListAccounts. An id Outstand does not return is an error (unknown account).

func (*Client) CreatePost

func (c *Client) CreatePost(ctx context.Context, in CreatePostInput) (Post, error)

CreatePost creates a post targeting in.AccountIDs. The account-id key is the STEP-2-locked AccountEncoderField.

func (*Client) DeletePost

func (c *Client) DeletePost(ctx context.Context, id string) error

DeletePost deletes (and cancels, if scheduled) a post by id.

func (*Client) GetPost

func (c *Client) GetPost(ctx context.Context, id string) (Post, error)

GetPost fetches a post by id.

func (*Client) ListAccounts

func (c *Client) ListAccounts(ctx context.Context) ([]Account, error)

ListAccounts returns the connected social accounts.

type Container

type Container struct {
	Content string `json:"content"`
}

Container is one content block of a post. Outstand posts carry an ordered list of content containers.

type CreatePostInput

type CreatePostInput struct {
	AccountIDs  []AccountID
	Containers  []Container
	ScheduledAt *time.Time
	LinkedIn    *LinkedInOptions
	Facebook    *FacebookOptions
}

CreatePostInput is the IDS-ONLY create-post request. AccountIDs is []AccountID (the constants.go type) by design: no raw string / network-name parameter exists anywhere in the public client surface, so a caller cannot target an account by an unvalidated name.

type Event

type Event struct {
	Type      string          // event type (EventPostPublished, ...); "" if unrecognizable
	PostID    string          // correlation id for post.* events; "" otherwise
	AccountID AccountID       // affected account for account.* events; "" otherwise
	Timestamp string          // vendor event timestamp (opaque); "" if absent
	Results   []AccountResult // per-account outcomes on post.* events; may be empty
	Raw       json.RawMessage // full verified payload, for dedup storage and debug
}

Event is a verified, decoded Outstand webhook. The decoder (UnmarshalJSON) is deliberately tolerant of key casing and nesting — Outstand's own JSON mixes snake_case and camelCase (e.g. isActive / created_at / network_unique_id), so field extraction survives minor shape differences. Authenticity is already guaranteed by the HMAC in ConstructEvent, so tolerant decoding is safe here.

func ConstructEvent

func ConstructEvent(rawBody []byte, sigHeader, secret string) (Event, error)

ConstructEvent verifies an Outstand webhook delivery and decodes it. sigHeader is the X-Outstand-Signature value ("sha256=<hex>"); the HMAC-SHA256 is computed over the RAW body bytes with the shared secret and compared in constant time (hmac.Equal). Only on a verified signature is the body unmarshalled. Each signature/format failure returns a distinct sentinel so a rejection is diagnosable from the log alone; a JSON error after successful verification is returned as-is (IsSignatureError == false) so the caller can ack rather than trigger retries.

func (*Event) UnmarshalJSON

func (e *Event) UnmarshalJSON(b []byte) error

UnmarshalJSON decodes an Outstand webhook envelope, tolerating snake/camel key casing and both top-level and nested-in-"data" placement of post_id / account_id / timestamp. Unknown shapes decode to a zero-valued Event (empty Type), which the dispatcher acks-and-ignores rather than erroring on.

type FacebookOptions

type FacebookOptions struct {
}

type FieldNameMode

type FieldNameMode int

FieldNameMode selects which JSON key carries the target account ids in a create-post request. Outstand's docs say socialAccountIds, but the LIVE API (STEP 2 probe, 2026-07-16) rejects that with 400 (path ["accounts"], "Required") and accepts "accounts". AccountEncoderField is locked to the verified key.

const (
	FieldSocialAccountIDs FieldNameMode = iota // "socialAccountIds" (per docs — REJECTED live)
	FieldAccounts                              // "accounts" (what the live API requires)
)

type Gateway

type Gateway interface {
	CreatePost(ctx context.Context, in CreatePostInput) (Post, error)
	GetPost(ctx context.Context, id string) (Post, error)
	DeletePost(ctx context.Context, id string) error
	ListAccounts(ctx context.Context) ([]Account, error)
	CheckAccountHealth(ctx context.Context, id AccountID) (bool, error)
}

Gateway abstracts the Outstand API surface this subsystem needs so callers (the publish worker, reconciliation) can be tested against a fake and never touch the live network. Mirrors stripeGateway.

type LinkedInOptions

type LinkedInOptions struct {
	Visibility string `json:"visibility,omitempty"` // e.g. "PUBLIC"
}

LinkedInOptions / FacebookOptions are per-network option structs. Only these two networks are modeled for now; others are added when a caller needs them.

type Post

type Post struct {
	ID               string
	Status           string
	TargetedAccounts []AccountID
	SocialAccounts   []PostAccount
}

Post is a decoded post resource (the fields this subsystem needs). It is an ALLOWLIST: only these fields are lifted from the vendor JSON. The full body is NEVER retained — Outstand's post resource embeds each connected account's OAuth tokens under socialAccounts[].network_data, and dropping the raw body is what keeps those tokens out of Post, logs, the ledger, tests, and any String()/error path.

type PostAccount

type PostAccount struct {
	Status          string
	Error           string
	PublishedAt     string
	PlatformPostID  string
	PlatformPostURL string
}

PostAccount is the per-network delivery state for one targeted account — an ALLOWLIST of non-secret fields. network_data (access_token / refresh_token / page_access_token) is DELIBERATELY not decoded, so a token can never reach a decoded Post.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL