Upwork-Go
Go types for the Upwork GraphQL API, generated from Upwork's API documentation.
This is an unofficial, community-maintained package. It is not affiliated with,
endorsed by, or supported by Upwork Global Inc.; "Upwork" is a trademark of Upwork
Global Inc.
Installation
go get github.com/andrewdevelop/upwork-go
Then import the package(s) you need from v2/..., e.g.
github.com/andrewdevelop/upwork-go/v2/entity — v2 here is a plain subdirectory of
this module, not a Go modules major-version suffix.
Requires Go 1.25+.
Packages
| Package |
Contents |
v2/entity |
Response entity types (object, enum, scalar types). Support database/sql via Scan/Value for storing entities as JSON columns. |
v2/input |
GraphQL input-object types used as mutation/query arguments (filters, create/update requests, etc). |
v2/queries |
One Go type per top-level GraphQL query field, holding its arguments. |
v2/mutations |
One Go type per top-level GraphQL mutation field, holding its arguments. |
v2/gql |
GraphQL-specific: document/selection-set building for queries/mutations, Execute/Query, and Upwork's errors[] shape (Error, Errors). |
v2/transport |
Protocol-agnostic HTTP client/transport: Client, RetryTransport, TokenRotationTransport, Chain — no GraphQL awareness, reusable for any HTTP call. |
v2/auth |
OAuth 2.0 grants against Upwork's authorization/token endpoints: Authorization Code Grant with PKCE (RFC 7636) and Client Credentials Grant. |
Everything under v2/entity, v2/input, v2/queries, and v2/mutations is generated
from Upwork's documentation and should not be hand-edited (see the DO NOT EDIT header
in each file).
Usage
Building a query/mutation request
Each type in queries/mutations represents one operation's arguments. Fill in the
fields you need (required arguments are plain values, optional ones are pointers), then
call Document with the response selection set you want, and Variables for the
GraphQL variables payload:
import (
"github.com/andrewdevelop/upwork-go/v2/entity"
"github.com/andrewdevelop/upwork-go/v2/queries"
)
q := queries.JobPosting{
JobPostingId: "2056509179207027383",
}
doc := q.Document(`
id
version
content { title description }
`)
vars := q.Variables() // map[string]any, e.g. {"jobPostingId": "2056509179207027383"}
doc is a complete GraphQL document string:
query JobPosting($jobPostingId: ID!, $loadAnnotation: Boolean) {
jobPosting(jobPostingId: $jobPostingId, loadAnnotation: $loadAnnotation) {
id
version
content { title description }
}
}
Send doc and vars to Upwork's GraphQL endpoint with any GraphQL/HTTP client, e.g.:
body, _ := json.Marshal(map[string]any{
"query": doc,
"variables": vars,
})
resp, err := http.Post("https://api.upwork.com/graphql", "application/json", bytes.NewReader(body))
Optional arguments are pointers — set them only when needed:
annotate := entity.Boolean(true)
q := queries.JobPosting{
JobPostingId: "123",
LoadAnnotation: &annotate,
}
Deriving the selection set from a Go struct
Writing the selection set as a raw string works, but you can also let it be derived
automatically from a Go struct via DocumentFor — define a struct shaped like the
response you want (json tags name the fields, same convention as entity), and pass
it in:
type jobPostingShape struct {
ID string `json:"id"`
Version int `json:"version"`
Content struct {
Title string `json:"title"`
Description string `json:"description"`
} `json:"content"`
}
doc := q.DocumentFor(jobPostingShape{})
This produces the same kind of document as Document, with the selection set built by
walking the struct's fields (nested structs become nested selections, slices/pointers
are unwrapped automatically). You can also pass an entity type directly — since those
already carry the right json tags — but note that deeply-nested/self-referential
entity types (e.g. entity.GenericOrganization.ParentOrganization) will have the cycle
cut short at an id field if one exists, or gql.Select (used internally by
DocumentFor) panics naming the offending field, telling you to supply a smaller custom
struct for it instead. In practice, defining your own small shape struct — as in the
example above — is the simplest way to avoid running into that.
Variables are unaffected by DocumentFor. The $jobPostingId: ID! declarations and
the jobPosting(jobPostingId: $jobPostingId, ...) call come from the operation's
declared arguments (baked in by the generator), independent of whichever shape struct
you pass for the selection set. Their values still come from q.Variables(), reading
the query/mutation struct (q) itself — not the shape struct:
q := queries.JobPosting{JobPostingId: "123"} // argument values live here
doc := q.DocumentFor(jobPostingShape{}) // selection set only
vars := q.Variables() // map[string]any{"jobPostingId": "123"}
body, _ := json.Marshal(map[string]any{"query": doc, "variables": vars})
Mutations work the same way, using types from mutations and, where the mutation
takes a structured payload, types from input:
import (
"github.com/andrewdevelop/upwork-go/v2/input"
"github.com/andrewdevelop/upwork-go/v2/mutations"
)
m := mutations.UpdateJobPosting{
Id: "4",
JobPostingRequest: &input.JobPostingUpdateRequest{
// ...
},
}
doc := m.Document("id version")
Decoding responses
Response payloads decode directly into v2/entity types via encoding/json:
var out struct {
Data struct {
JobPosting entity.JobPosting `json:"jobPosting"`
} `json:"data"`
}
json.Unmarshal(respBody, &out)
Calling the API over HTTP
The body/http.Post snippet above is the whole contract — bring your own HTTP client
and it works as-is:
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
func run() error {
q := queries.JobPosting{JobPostingId: "2056509179207027383"}
doc := q.Document("id version content { title description }")
body, err := json.Marshal(map[string]any{
"query": doc,
"variables": q.Variables(),
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
"https://api.upwork.com/graphql", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var out struct {
Data struct {
JobPosting entity.JobPosting `json:"jobPosting"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return err
}
if len(out.Errors) > 0 {
return fmt.Errorf("graphql: %s", out.Errors[0].Message)
}
fmt.Println(out.Data.JobPosting.Content.Title)
return nil
}
gql.Execute — a lightweight facade over your own *http.Client
gql.Execute is a plain function built on net/http — no client type of its
own to reach for. You pass your own *http.Client (whatever Transport,
timeout, or retry policy you use elsewhere — see v2/transport
for ready-made ones), and it derives the selection set from a shape struct
(via DocumentFor), sends the request, unwraps the operation's field out of
data, and decodes it into that shape:
import (
"context"
"net/http"
"github.com/andrewdevelop/upwork-go/v2/gql"
"github.com/andrewdevelop/upwork-go/v2/queries"
)
type jobPostingShape struct {
ID string `json:"id"`
Version int `json:"version"`
Content struct {
Title string `json:"title"`
} `json:"content"`
}
header := http.Header{"Authorization": []string{"Bearer " + accessToken}}
result, err := gql.Execute(context.Background(), http.DefaultClient,
"https://api.upwork.com/graphql", header,
queries.JobPosting{JobPostingId: "2056509179207027383"},
jobPostingShape{},
)
if err != nil {
// A non-nil error here can be transport/decode failure, or a
// gql.Errors value from the GraphQL response's "errors" array.
}
fmt.Println(result.Content.Title)
Pass nil instead of an *http.Client to use http.DefaultClient, or your own
instance configured however your project already configures outbound HTTP calls.
gql.NewClient(httpClient) / gql.Query(ctx, client, op, shape) are the
higher-level counterpart, if you'd rather hold onto a reusable, immutable
client than pass endpoint/header to Execute by hand every call —
gql.NewClient returns a *transport.Client (see below) pre-set to
gql.DefaultEndpoint.
GraphQL error handling — why a 200 OK can still be an error
Upwork's GraphQL API always answers with HTTP 200, even when the operation
failed — a non-2xx status only ever means the request failed before reaching
the GraphQL layer (auth, a 503, ...; see transport.StatusError, returned by
Execute/gql.Query for that case). Otherwise, failures come back as a
gql.Errors value decoded from the response body's "errors" array — check
for it with errors.As:
result, err := gql.Query(ctx, client, someQuery, someShape{})
var gqlErrs gql.Errors
switch {
case errors.As(err, &gqlErrs):
if gqlErrs.HasClassification(gql.ClassificationValidationError) {
// malformed query: missing/null/wrongly-typed argument, etc.
} else if gqlErrs.IsPermissionDenied() {
// access token lacks the oauth2 scope for a requested field
}
case err != nil:
// transport/decode failure, or a *transport.StatusError
}
Upwork's API is inconsistent about how much structure an error carries: a
query-validation failure includes Locations and
Extensions["classification"] (gql.Error.Classification(),
gql.ClassificationValidationError); an authorization failure includes
neither, just a Message naming the missing scopes —
Errors.IsPermissionDenied matches that case by wording, as a best-effort
convenience rather than a contract (Upwork could change the message).
Storing entities in a database
v2/entity types implement sql.Scanner/driver.Valuer (JSON-encoded), so they can be
read from and written to a JSON/JSONB column directly:
var jp entity.JobPosting
db.QueryRow(`SELECT payload FROM job_postings WHERE id = $1`, id).Scan(&jp)
db.Exec(`INSERT INTO job_postings (id, payload) VALUES ($1, $2)`, jp.ID, jp)
A handful of types (BillRate, JobPostingCustomFields, StringMapElement,
WorkflowTaskCustomAttribute) have their own Value field, which collides with the
Value() method Go requires for driver.Valuer. These implement Scan only; encode
them for writes with entity package internals or json.Marshal directly.
Authentication (v2/auth)
v2/auth implements Upwork's OAuth 2.0 grants, one type per grant so the
package has room to grow (see its package doc for what's planned beyond
what's below — an Implicit Grant). Two grants are implemented today.
The Authorization Code Grant with PKCE is the flow Upwork recommends over
the deprecated Implicit Grant, for anything acting on behalf of a
logged-in Upwork user:
import (
"context"
"net/http"
"github.com/andrewdevelop/upwork-go/v2/auth"
)
grant := auth.AuthorizationCodeGrant{
ClientID: "YOUR_CLIENT_ID",
ClientSecret: "YOUR_CLIENT_SECRET",
RedirectURI: "https://example.com/oauth/callback",
}
// Step 1: generate a PKCE pair and CSRF state, then redirect the user.
pkce, err := auth.NewPKCE()
state, err := auth.NewState()
redirectURL := grant.AuthorizationURL(state, pkce.Challenge)
// Persist pkce.Verifier and state (e.g. in the user's session) until the callback.
// Step 2: in the callback handler, verify the "state" query parameter
// matches what you stored, then exchange the "code" query parameter.
token, err := grant.Exchange(context.Background(), http.DefaultClient, code, pkce.Verifier)
// token.AccessToken, token.RefreshToken, token.ExpiresIn (seconds)
// Later, once the access token expires:
token, err = grant.Refresh(context.Background(), http.DefaultClient, token.RefreshToken)
Exchange and Refresh accept any *http.Client (nil uses
http.DefaultClient), consistent with gql.Execute.
The Client Credentials Grant is server-to-server, with no redirect and no
PKCE — a client ID/secret pair exchanges directly for an access token. It's
only available to Upwork Enterprise consumers, and the response carries no
refresh token; request a new one with another Token call once the
current one's ExpiresIn runs out:
grant := auth.ClientCredentialsGrant{
ClientID: "YOUR_CLIENT_ID",
ClientSecret: "YOUR_CLIENT_SECRET",
}
token, err := grant.Token(context.Background(), http.DefaultClient)
This is also what backs Upwork's Service Accounts: apply for a Client
Credentials key with the "create a Client Credential key for Service
Account" option, and Upwork associates it with a Service Account on its
side — the token request itself is identical, ClientCredentialsGrant as
above. Assign scopes/permissions to the Service Account in Upwork's
dashboard; Upwork's docs describe Service Accounts as intended for
read-only use.
HTTP transport (v2/transport)
v2/transport is protocol-agnostic HTTP client/transport machinery — an
authenticated Client plus a set of composable http.RoundTripper
decorators. None of it knows anything about GraphQL; v2/gql builds
Upwork's GraphQL client on top of transport.Client (gql.NewClient,
gql.Execute, gql.Query), but everything here works just as well for a
plain REST call.
transport.TokenRotationTransport attaches Authorization: Bearer <token>
to every request made through the *http.Client it's installed on, and on
a 401 refreshes the token via a grant and retries once. Concurrent 401s
coalesce onto a single refresh call:
httpClient := &http.Client{
Transport: transport.NewTokenRotationTransport(transport.NewRetryTransport(nil), grant, token,
func(ctx context.Context, refreshed *auth.Token) error {
return sessionStore.Save(ctx, sessionID, refreshed)
}),
}
client := gql.NewClient(httpClient)
For more than two links, transport.Chain reads better than nesting
constructor calls — the last middleware given ends up outermost (first to
see the request), so top-to-bottom order in the call matches request-flow
order:
rt := transport.Chain(nil,
func(next http.RoundTripper) http.RoundTripper { return transport.NewRetryTransport(next) },
func(next http.RoundTripper) http.RoundTripper {
return transport.NewTokenRotationTransport(next, grant, token, onRefresh)
},
// ...your own logging/tracing http.RoundTripper, etc.
)
transport.RetryTransport is a standard http.RoundTripper that retries
transient failures — network errors, and by default any 5xx status (a
502/504 from a proxy in front of an API is as common as a 500/503 from the
origin itself) — with exponential backoff and jitter, honoring a
Retry-After header when present:
httpClient := &http.Client{Transport: transport.NewRetryTransport(nil)} // 3 retries, 200ms–5s backoff, any 5xx
client := gql.NewClient(httpClient)
Set RetryStatus to retry a different set of codes instead — narrower (only
503), wider (add 429 Too Many Requests), or anything else func(status int) bool
can express:
rt := transport.NewRetryTransport(nil)
rt.RetryStatus = func(status int) bool {
return status == http.StatusTooManyRequests || transport.DefaultRetryStatus(status)
}
It only retries a request whose body it can safely resend (none, or one
net/http can auto-replay via GetBody — true for gql.Execute's own
requests), and composes with any other http.RoundTripper you layer on top.
Want different retry logic, or none at all? Just don't use it — nothing else
in v2/transport depends on it.
Neither decorator is wired into gql.Query automatically — it does no
retrying of its own, by design: configure retry/rotation once on the
*http.Client's Transport, and it applies uniformly to every request made
through it. See examples/basic for a full runnable OAuth2 + GraphQL web
app built on transport.Client, TokenRotationTransport, and
RetryTransport together.
Notes
v2/input covers only the input-object types actually referenced (directly or
transitively) by the generated queries/mutations — not the full input-object surface
of the schema.
- Query/mutation
Document does not hardcode a response selection set: Upwork's docs
use unresolvable ...FragmentName placeholders for nested objects in several
examples, so the caller supplies the selection explicitly.
License
MIT