Below is a fully merged, production-grade ingress example showing:
-
How to expose:
- ApprovalService.RequestApproval
- ApprovalService.HandleApprovalCallback
- OnboardingWorkflow.Run
- OnboardingWorkflow.VerifyEmail
-
How clients call these through an HTTP ingress.
-
REST endpoints that cleanly map to Restate service invocations.
-
Proper serialization into Restate’s durable execution model.
-
Durable workflow instance routing (workflow keying).
-
A real “human-in-the-loop” callback path.
-
A real workflow signal invocation path.
This is minimal but complete and can be dropped into your main.go or a separate file.
You end up exposing:
| HTTP Method | Path | What it does |
|---|---|---|
| POST | /approval/start |
Calls ApprovalService.RequestApproval to begin waiting for a human |
| POST | /approval/callback |
External callback that resolves HandleApprovalCallback |
| HTTP Method | Path | What it does |
|---|---|---|
| POST | /onboarding/start/:userID |
Starts workflow OnboardingWorkflow.Run for that user |
| POST | /onboarding/verify/:userID |
External service invokes OnboardingWorkflow.VerifyEmail |
This uses:
- net/http
- server.NewRestate() for Restate runtime
- server.NewIngress() for HTTP routing to Restate
- Proper Request / Send / WorkflowRun invocation patterns
All code uses your existing ApprovalService and OnboardingWorkflow types.
package main
import (
"context"
"encoding/json"
"log"
"net/http"
restate "github.com/restatedev/sdk-go"
"github.com/restatedev/sdk-go/server"
)
// -----------------------------------------------------------------------------
// HTTP INGRESS SERVER
// -----------------------------------------------------------------------------
func main() {
// -------------------------------------------------------------
// 1. Create the Restate runtime
// -------------------------------------------------------------
rt := server.NewRestate().
Bind(restate.Reflect(&ApprovalService{})).
Bind(restate.Reflect(&OnboardingWorkflow{})).
Bind(restate.Reflect(&UserSettings{})) // optional
// -------------------------------------------------------------
// 2. Create an ingress router (HTTP -> Restate)
// -------------------------------------------------------------
ing := server.NewIngress(rt)
//
// APPROVAL SERVICE ROUTES
//
// POST /approval/start
// { "amount": 150, "description": "Purchase ABC" }
ing.HandleFunc("POST", "/approval/start", func(w http.ResponseWriter, r *http.Request) {
var req ApprovalRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), 400)
return
}
// Invoke the Restate service
client := restate.NewClient[ApprovalService](rt)
resp, err := client.RequestApproval(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(map[string]any{
"result": resp,
"note": "Use the logged awakeable ID to approve or reject",
})
})
// POST /approval/callback
// { "awakeable_id": "...", "approved": true }
ing.HandleFunc("POST", "/approval/callback", func(w http.ResponseWriter, r *http.Request) {
var payload CallbackPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
client := restate.NewClient[ApprovalService](rt)
_, err := client.HandleApprovalCallback(r.Context(), payload)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(map[string]string{
"status": "callback processed",
})
})
//
// ONBOARDING WORKFLOW ROUTES
//
// POST /onboarding/start/:userID
ing.HandleFunc("POST", "/onboarding/start/{userID}", func(w http.ResponseWriter, r *http.Request) {
userID := server.PathParam(r, "userID")
client := restate.NewWorkflowClient[OnboardingWorkflow](rt)
result, err := client.Run(r.Context(), userID, userID) // workflow key = userID
if err != nil {
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(map[string]any{
"user": userID,
"result": result,
})
})
// POST /onboarding/verify/:userID
// Body: { "verified": true }
ing.HandleFunc("POST", "/onboarding/verify/{userID}", func(w http.ResponseWriter, r *http.Request) {
userID := server.PathParam(r, "userID")
var body struct {
Verified bool `json:"verified"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), 400)
return
}
client := restate.NewWorkflowClient[OnboardingWorkflow](rt)
_, err := client.VerifyEmail(r.Context(), userID, body.Verified)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(map[string]any{
"user": userID,
"status": "verification processed",
"updated": body.Verified,
})
})
// -------------------------------------------------------------
// 3. Start both the Restate runtime and the ingress server
// -------------------------------------------------------------
go func() {
log.Println("Restate runtime on :2222")
if err := rt.Start(context.Background(), ":2222"); err != nil {
log.Fatalf("restate runtime failed: %v", err)
}
}()
log.Println("HTTP ingress on :8080")
if err := http.ListenAndServe(":8080", ing); err != nil {
log.Fatalf("http ingress failed: %v", err)
}
}This is a working HTTP ingress layer that:
- Accepts HTTP.
- Converts HTTP requests to Restate service calls.
- Allows external systems to send callbacks for awakeables.
- Allows external systems to send signals to the onboarding workflow.
POST http://localhost:8080/approval/start
{
"amount": 2000,
"description": "Overtime pay"
}
Server logs:
WaitForExternalSignal -> awakeableID=awakeable:xxx
POST http://localhost:8080/approval/callback
{
"awakeable_id": "awakeable:xxx",
"approved": true
}
Workflow resumes and completes.
POST http://localhost:8080/onboarding/start/alice
Workflow suspends waiting for email.
POST http://localhost:8080/onboarding/verify/alice
{
"verified": true
}
Workflow resumes and completes.
Q: based on attached files websvc.go.txt is the restate ingress client and run.go.txt is the restate handler, which decides if the email is verified (ingress client or the restate handler), elaborate
Based on the provided code, I can explain how email verification is decided in this system:
The ingress client (websvc.go.txt) provides an HTTP endpoint /onboarding/verify/{userID} that:
- Receives an HTTP request with a JSON body containing a boolean
verifiedfield - Validates the request against a JSON schema
- Forwards this verification decision to the Restate workflow
The relevant code from websvc.go.txt:
func handleOnboardingVerify(w http.ResponseWriter, r *http.Request) {
// ... (validation code)
// This maps to a shared handler on the workflow instance (signal)
status, respBody, err := callRestateFunction(ctx, "OrderWorkflow", "VerifyEmail", &userID, body)
// ... (response handling)
}The Restate handler (run.go.txt) contains the OrderWorkflow which:
- Has a
Runmethod that waits for an email verification signal - Has a
VerifyEmailshared handler that receives and processes the verification decision
The relevant code from run.go.txt:
// In the Run method:
// Use a promise: wait for internal verification
emailPromise := GetInternalSignal[bool](ctx, "email-verified")
verified, pErr := emailPromise.Result()
if pErr != nil {
err = pErr
return "", err
}
if !verified {
err = fmt.Errorf("email not verified")
return "", err
}
// The VerifyEmail handler:
func (w *OrderWorkflow) VerifyEmail(ctx restate.WorkflowSharedContext, v bool) (restate.Void, error) {
p := GetInternalSignal[bool](ctx, "email-verified")
return restate.Void{}, p.Resolve(v)
}The ingress client does not decide if the email is verified. It simply acts as a bridge between external systems and the Restate workflow. The actual verification decision comes from:
- An external system (not shown in the code) that determines if the email is verified
- This external system makes an HTTP request to the ingress client with the verification result
- The ingress client forwards this decision to the Restate workflow
- The Restate workflow uses this decision to determine whether to continue or fail the workflow
The Restate handler (OrderWorkflow) is the component that ultimately uses the verification decision to control the flow of the workflow, but it doesn't make the verification decision itself - it merely receives and acts upon the decision provided by the external system via the ingress client.
Q: I don't see a bool value provided in the body of handleOnboardingVerify, is there a disconnect between the websvc.go.txt and run.go.txt or is the example incomplete?
A: You have an excellent eye for detail! You are absolutely correct. There is a disconnect between the ingress client (websvc.go.txt) and the Restate handler (run.go.txt). The example is indeed inconsistent in this specific part.
Let's break down the mismatch:
The handleOnboardingVerify function is designed to accept a JSON object.
-
The Schema: The
verifyEmailLoaderschema explicitly defines this:{ "type": "object", "required": ["verified"], "properties": { "verified": { "type": "boolean" } } }This schema validates a JSON body that looks like this:
{"verified": true}or{"verified": false}. -
The Code: The handler reads the entire body (
body, err := io.ReadAll(r.Body)) and passes that raw[]byteslice directly to the Restate function:// body contains []byte(`{"verified": true}`) status, respBody, err := callRestateFunction(ctx, "OrderWorkflow", "VerifyEmail", &userID, body)
So, the ingress service is correctly set up to send a JSON object with a verified field.
The VerifyEmail handler in the OrderWorkflow has the following signature:
func (w *OrderWorkflow) VerifyEmail(ctx restate.WorkflowSharedContext, v bool) (restate.Void, error)This handler expects the Restate SDK to deserialize the incoming payload into a single bool variable named v.
The Restate SDK will receive the payload {"verified": true} and try to unmarshal it into a bool. This will fail, because a JSON object {"key": value} cannot be deserialized into a primitive Go type like bool. The SDK would expect the payload to be just the raw boolean value, e.g., true or false (without the JSON object wrapper).
The most idiomatic way to fix this is to change the Restate handler to accept a struct that matches the JSON object being sent. This makes the API more explicit and extensible.
Here is the corrected code for run.go.txt:
// Add a new request type for clarity and correctness
type VerifyEmailRequest struct {
Verified bool `json:"verified"`
}
// ... inside the OrderWorkflow struct ...
// Workflow shared handler to resolve promise (CORRECTED)
func (w *OrderWorkflow) VerifyEmail(ctx restate.WorkflowSharedContext, req VerifyEmailRequest) (restate.Void, error) {
p := GetInternalSignal[bool](ctx, "email-verified")
// Use the value from the request struct
return restate.Void{}, p.Resolve(req.Verified)
}- Define a Struct: We create a
VerifyEmailRequeststruct with aVerifiedboolean field and ajson:"verified"tag to match the incoming JSON. - Update Handler Signature: We change the
VerifyEmailhandler to acceptreq VerifyEmailRequestinstead ofv bool. - Use the Struct Field: Inside the handler, we access the boolean value via
req.Verified.
With this change, the Restate SDK knows exactly how to map the incoming JSON object {"verified": true} to the VerifyEmailRequest struct, resolving the inconsistency. Your observation was spot on—this is a critical detail that would prevent the email verification from working correctly.
Q: so in func (w *OrderWorkflow) VerifyEmail if req.Verified is false is it still correct to call p.Resolve or should it be p.Reject?
A:
That is an excellent and insightful question that gets to the heart of using promises and awakeables correctly.
You should still call p.Resolve with the false value.
Here’s the critical distinction:
Resolve(value): Use this when the asynchronous operation completes successfully and you are providing its result. The result itself can be a "negative" one, likefalseor an error message. The key is that the operation finished.Reject(error): Use this when the asynchronous operation fails to produce a result due to a system error, timeout, or other exceptional circumstance.
The operation here is "check the email verification status". There are two valid, expected outcomes for this operation:
- The email is verified (
verified: true). - The email is not verified (
verified: false).
Both of these are successful completions of the check. The workflow is designed to handle both outcomes, as seen in the Run method:
// ... in OrderWorkflow.Run
// Use a promise: wait for internal verification
emailPromise := GetInternalSignal[bool](ctx, "email-verified")
verified, pErr := emailPromise.Result()
// This handles a REJECT
if pErr != nil {
err = pErr
return "", err
}
// This handles a RESOLVE with a value of `false`
if !verified {
err = fmt.Errorf("email not verified") // A business logic error
return "", err
}
// ... continue workflow if verified is trueBy calling p.Resolve(false), you are correctly informing the workflow: "The check is complete, and the result is false." The workflow then receives this false value and executes its business logic to stop the order processing.
You would use p.Reject if the verification process itself failed. For example:
- The verification link/token expired.
- The service that checks the verification status was down.
- The request to
VerifyEmailwas malformed or missing required data.
In that case, the VerifyEmail handler might look like this:
// Hypothetical example of using Reject
func (w *OrderWorkflow) VerifyEmail(ctx restate.WorkflowSharedContext, req VerifyEmailRequest) (restate.Void, error) {
p := GetInternalSignal[bool](ctx, "email-verified")
// Imagine we had some logic that could fail
if req.Verified {
return restate.Void{}, p.Resolve(true)
} else {
// Let's say a false result means the token was invalid/expired
// This is a system failure, not a simple "not verified" state.
return restate.Void{}, p.Reject(fmt.Errorf("verification token is invalid or expired"))
}
}In this hypothetical scenario, emailPromise.Result() in the Run method would return an error, and the workflow would terminate due to the if pErr != nil block.
Conclusion: Your current approach of calling p.Resolve(req.Verified) is the correct and most semantically accurate design. It cleanly separates a valid business outcome (false) from a system failure (which would use Reject).